arcane-agents 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +64 -48
  2. package/config.example.yaml +1 -0
  3. package/dist/client/assets/index-CT0NFttM.css +32 -0
  4. package/dist/client/assets/index-DHyA1AST.js +83 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/server/bootstrap/serverContext.js +8 -3
  7. package/dist/server/server/cli.js +120 -2
  8. package/dist/server/server/config/schema.js +5 -1
  9. package/dist/server/server/http/routes/registerApiRoutes.js +10 -0
  10. package/dist/server/server/orchestrator/orchestratorService.js +29 -0
  11. package/dist/server/server/orchestrator/orchestratorService.test.js +59 -0
  12. package/dist/server/server/orchestrator/spawn/resolveSpawnPlan.test.js +1 -0
  13. package/dist/server/server/setup/prerequisites.js +74 -0
  14. package/dist/server/server/setup/prerequisites.test.js +50 -0
  15. package/dist/server/server/status/activityParser.js +1 -1
  16. package/dist/server/server/status/engine/signalContext.js +20 -7
  17. package/dist/server/server/status/engine/stateMachine/constants.js +6 -2
  18. package/dist/server/server/status/engine/stateMachine/decision.js +17 -0
  19. package/dist/server/server/status/engine/stateMachine/decision.test.js +64 -0
  20. package/dist/server/server/status/engine/stateMachine/helpers.js +7 -1
  21. package/dist/server/server/status/engine/stateMachine/helpers.test.js +10 -1
  22. package/dist/server/server/status/engine/stateMachine/idleBlockers.js +13 -0
  23. package/dist/server/server/status/engine/stateMachine/workingEvidence.js +38 -0
  24. package/dist/server/server/status/runtime/activityTextExtractors.js +40 -0
  25. package/dist/server/server/status/runtime/claudeSignals.js +114 -33
  26. package/dist/server/server/status/runtime/claudeSignals.test.js +23 -0
  27. package/dist/server/server/status/runtime/codexSignals.js +132 -0
  28. package/dist/server/server/status/runtime/codexSignals.test.js +47 -0
  29. package/dist/server/server/status/runtime/runtimeProcess.js +86 -0
  30. package/dist/server/server/status/runtime/sessionDetection.js +15 -0
  31. package/dist/server/server/status/runtimeSignals.js +8 -1
  32. package/dist/server/server/status/statusEvaluator.js +4 -2
  33. package/dist/server/server/status/statusMonitor.js +22 -4
  34. package/dist/server/server/status/statusMonitor.test.js +9 -2
  35. package/dist/server/server/status/statusPipeline.js +14 -4
  36. package/dist/server/server/tmux/tmuxAdapter.js +30 -51
  37. package/dist/server/server/tmux/tmuxClient.js +35 -0
  38. package/dist/server/server/tmux/tmuxClient.test.js +58 -0
  39. package/dist/server/server/ws/terminalBridge.js +16 -2
  40. package/dist/server/shared/mapConstants.js +4 -0
  41. package/package.json +4 -3
  42. package/dist/client/assets/index-CWU29xaz.css +0 -32
  43. package/dist/client/assets/index-DNXJVqF0.js +0 -83
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findAgentRuntimeProcess = findAgentRuntimeProcess;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_util_1 = require("node:util");
6
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
7
+ const maxProcessTreeDepth = 5;
8
+ async function findAgentRuntimeProcess(panePid) {
9
+ return findAgentRuntimeProcessAtDepth(panePid, 0);
10
+ }
11
+ async function findAgentRuntimeProcessAtDepth(parentPid, depth) {
12
+ if (depth >= maxProcessTreeDepth) {
13
+ return undefined;
14
+ }
15
+ try {
16
+ const { stdout } = await execFileAsync("pgrep", ["-P", String(parentPid)], {
17
+ maxBuffer: 1024 * 16
18
+ });
19
+ const childPids = stdout
20
+ .trim()
21
+ .split("\n")
22
+ .map((line) => Number.parseInt(line.trim(), 10))
23
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
24
+ for (const childPid of childPids) {
25
+ const details = await describeProcess(childPid);
26
+ if (details) {
27
+ const runtime = classifyAgentRuntime(details.command, details.args);
28
+ if (runtime) {
29
+ return {
30
+ pid: childPid,
31
+ runtime,
32
+ command: details.command,
33
+ args: details.args
34
+ };
35
+ }
36
+ }
37
+ const nestedMatch = await findAgentRuntimeProcessAtDepth(childPid, depth + 1);
38
+ if (nestedMatch) {
39
+ return nestedMatch;
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ return undefined;
47
+ }
48
+ async function describeProcess(pid) {
49
+ try {
50
+ const { stdout } = await execFileAsync("ps", ["-o", "comm=", "-o", "args=", "-p", String(pid)], {
51
+ maxBuffer: 1024 * 16
52
+ });
53
+ const line = stdout.trim();
54
+ if (!line) {
55
+ return undefined;
56
+ }
57
+ const [command = "", ...argsParts] = line.split(/\s+/);
58
+ return {
59
+ command: command.trim(),
60
+ args: argsParts.join(" ").trim()
61
+ };
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ function classifyAgentRuntime(command, args) {
68
+ const commandLower = command.trim().toLowerCase();
69
+ const argsLower = args.trim().toLowerCase();
70
+ const commandAndArgs = `${commandLower} ${argsLower}`.trim();
71
+ if (commandLower === "claude" ||
72
+ commandAndArgs.includes("/claude") ||
73
+ /\bclaude(?:-code)?\b/.test(commandAndArgs)) {
74
+ return "claude";
75
+ }
76
+ if (commandLower === "opencode" || commandAndArgs.includes("opencode")) {
77
+ return "opencode";
78
+ }
79
+ if (commandLower === "codex" ||
80
+ commandAndArgs.includes("@openai/codex") ||
81
+ commandAndArgs.includes("/bin/codex") ||
82
+ /\bcodex\b/.test(commandAndArgs)) {
83
+ return "codex";
84
+ }
85
+ return undefined;
86
+ }
@@ -3,13 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.capturePaneLineCount = capturePaneLineCount;
4
4
  exports.isLikelyClaudeSession = isLikelyClaudeSession;
5
5
  exports.isLikelyOpenCodeSession = isLikelyOpenCodeSession;
6
+ exports.isLikelyCodexSession = isLikelyCodexSession;
6
7
  const defaultCapturePaneLines = 35;
7
8
  const claudeCapturePaneLines = 60;
8
9
  const openCodeCapturePaneLines = 420;
10
+ const codexCapturePaneLines = 420;
9
11
  function capturePaneLineCount(worker, commandLower) {
10
12
  if (isLikelyOpenCodeSession(worker, commandLower)) {
11
13
  return openCodeCapturePaneLines;
12
14
  }
15
+ if (isLikelyCodexSession(worker, commandLower)) {
16
+ return codexCapturePaneLines;
17
+ }
13
18
  if (isLikelyClaudeSession(worker, commandLower)) {
14
19
  return claudeCapturePaneLines;
15
20
  }
@@ -35,3 +40,13 @@ function isLikelyOpenCodeSession(worker, commandLower) {
35
40
  }
36
41
  return commandLower.includes("opencode");
37
42
  }
43
+ function isLikelyCodexSession(worker, commandLower) {
44
+ if (worker.runtimeId.toLowerCase().includes("codex")) {
45
+ return true;
46
+ }
47
+ const runtimeBinary = worker.command[0]?.toLowerCase() ?? "";
48
+ if (runtimeBinary.includes("codex")) {
49
+ return true;
50
+ }
51
+ return commandLower.includes("codex");
52
+ }
@@ -1,19 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.extractRuntimeActivityText = exports.hasWaitingActivityText = exports.hasActiveWorkActivityText = exports.preferOpenCodeSpecificActivityText = exports.hasOpenCodeActiveSignal = exports.hasOpenCodePromptSignal = exports.detectOpenCodeSignals = exports.isGenericClaudeProgressLabel = exports.hasClaudeLiveProgressSignal = exports.extractClaudeActiveTask = exports.isLikelyOpenCodeSession = exports.isLikelyClaudeSession = exports.capturePaneLineCount = void 0;
3
+ exports.extractRuntimeActivityText = exports.hasWaitingActivityText = exports.hasActiveWorkActivityText = exports.hasCodexActiveSignal = exports.hasCodexPromptSignal = exports.detectCodexSignals = exports.preferOpenCodeSpecificActivityText = exports.hasOpenCodeActiveSignal = exports.hasOpenCodePromptSignal = exports.detectOpenCodeSignals = exports.isGenericClaudeProgressLabel = exports.hasClaudePromptSignal = exports.hasClaudeLiveProgressSignal = exports.extractClaudeActiveTask = exports.detectClaudeSignals = exports.isLikelyCodexSession = exports.isLikelyOpenCodeSession = exports.isLikelyClaudeSession = exports.capturePaneLineCount = void 0;
4
4
  var sessionDetection_1 = require("./runtime/sessionDetection");
5
5
  Object.defineProperty(exports, "capturePaneLineCount", { enumerable: true, get: function () { return sessionDetection_1.capturePaneLineCount; } });
6
6
  Object.defineProperty(exports, "isLikelyClaudeSession", { enumerable: true, get: function () { return sessionDetection_1.isLikelyClaudeSession; } });
7
7
  Object.defineProperty(exports, "isLikelyOpenCodeSession", { enumerable: true, get: function () { return sessionDetection_1.isLikelyOpenCodeSession; } });
8
+ Object.defineProperty(exports, "isLikelyCodexSession", { enumerable: true, get: function () { return sessionDetection_1.isLikelyCodexSession; } });
8
9
  var claudeSignals_1 = require("./runtime/claudeSignals");
10
+ Object.defineProperty(exports, "detectClaudeSignals", { enumerable: true, get: function () { return claudeSignals_1.detectClaudeSignals; } });
9
11
  Object.defineProperty(exports, "extractClaudeActiveTask", { enumerable: true, get: function () { return claudeSignals_1.extractClaudeActiveTask; } });
10
12
  Object.defineProperty(exports, "hasClaudeLiveProgressSignal", { enumerable: true, get: function () { return claudeSignals_1.hasClaudeLiveProgressSignal; } });
13
+ Object.defineProperty(exports, "hasClaudePromptSignal", { enumerable: true, get: function () { return claudeSignals_1.hasClaudePromptSignal; } });
11
14
  Object.defineProperty(exports, "isGenericClaudeProgressLabel", { enumerable: true, get: function () { return claudeSignals_1.isGenericClaudeProgressLabel; } });
12
15
  var openCodeSignals_1 = require("./runtime/openCodeSignals");
13
16
  Object.defineProperty(exports, "detectOpenCodeSignals", { enumerable: true, get: function () { return openCodeSignals_1.detectOpenCodeSignals; } });
14
17
  Object.defineProperty(exports, "hasOpenCodePromptSignal", { enumerable: true, get: function () { return openCodeSignals_1.hasOpenCodePromptSignal; } });
15
18
  Object.defineProperty(exports, "hasOpenCodeActiveSignal", { enumerable: true, get: function () { return openCodeSignals_1.hasOpenCodeActiveSignal; } });
16
19
  Object.defineProperty(exports, "preferOpenCodeSpecificActivityText", { enumerable: true, get: function () { return openCodeSignals_1.preferOpenCodeSpecificActivityText; } });
20
+ var codexSignals_1 = require("./runtime/codexSignals");
21
+ Object.defineProperty(exports, "detectCodexSignals", { enumerable: true, get: function () { return codexSignals_1.detectCodexSignals; } });
22
+ Object.defineProperty(exports, "hasCodexPromptSignal", { enumerable: true, get: function () { return codexSignals_1.hasCodexPromptSignal; } });
23
+ Object.defineProperty(exports, "hasCodexActiveSignal", { enumerable: true, get: function () { return codexSignals_1.hasCodexActiveSignal; } });
17
24
  var textSignals_1 = require("./runtime/textSignals");
18
25
  Object.defineProperty(exports, "hasActiveWorkActivityText", { enumerable: true, get: function () { return textSignals_1.hasActiveWorkActivityText; } });
19
26
  Object.defineProperty(exports, "hasWaitingActivityText", { enumerable: true, get: function () { return textSignals_1.hasWaitingActivityText; } });
@@ -3,15 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.evaluateWorkerStatus = evaluateWorkerStatus;
4
4
  const signalContext_1 = require("./engine/signalContext");
5
5
  const stateMachine_1 = require("./engine/stateMachine");
6
- function evaluateWorkerStatus({ worker, currentCommand, output, observation, transcriptSnapshot, interactiveCommands }) {
6
+ function evaluateWorkerStatus({ worker, currentCommand, output, observation, transcriptSnapshot, runtimeProcess, interactiveCommands, runtimeFreshnessWindowMs }) {
7
7
  const context = (0, signalContext_1.buildWorkerStatusSignalContext)({
8
8
  worker,
9
9
  currentCommand,
10
10
  output,
11
11
  observation,
12
12
  transcriptSnapshot,
13
+ runtimeProcess,
13
14
  nowMs: Date.now(),
14
- interactiveCommands
15
+ interactiveCommands,
16
+ runtimeFreshnessWindowMs
15
17
  });
16
18
  return (0, stateMachine_1.deriveWorkerStatusDecision)(context);
17
19
  }
@@ -10,10 +10,15 @@ const defaultDecisionFacts = {
10
10
  workerAgeMs: 0,
11
11
  isClaudeSession: false,
12
12
  isOpenCodeSession: false,
13
+ isCodexSession: false,
14
+ hasClaudePromptSignal: false,
13
15
  hasOpenCodePromptSignal: false,
14
16
  hasOpenCodeActiveSignal: false,
17
+ hasCodexPromptSignal: false,
18
+ hasCodexActiveSignal: false,
15
19
  hasClaudeProgressSignal: false,
16
20
  hasActiveClaudeTask: false,
21
+ hasActiveRuntimeProcess: false,
17
22
  hasRuntimeActivityText: false,
18
23
  hasParsedStrongSignal: false,
19
24
  hasParsedNeedsInput: false,
@@ -41,6 +46,7 @@ class StatusMonitor {
41
46
  traceMode = resolveStatusTraceMode();
42
47
  workerPollConcurrency = resolveStatusPollConcurrency();
43
48
  interactiveCommands;
49
+ runtimeFreshnessOverrides;
44
50
  constructor(workers, tmux, pollIntervalMs, onWorkerUpdated, onWorkerRemoved, config) {
45
51
  this.workers = workers;
46
52
  this.tmux = tmux;
@@ -48,6 +54,13 @@ class StatusMonitor {
48
54
  this.onWorkerUpdated = onWorkerUpdated;
49
55
  this.onWorkerRemoved = onWorkerRemoved;
50
56
  this.interactiveCommands = new Set(config.status.interactiveCommands.map((cmd) => cmd.toLowerCase()));
57
+ const freshnessOverrides = new Map();
58
+ for (const [id, runtime] of Object.entries(config.runtimes)) {
59
+ if (runtime.freshnessWindowMs !== undefined) {
60
+ freshnessOverrides.set(id, runtime.freshnessWindowMs);
61
+ }
62
+ }
63
+ this.runtimeFreshnessOverrides = freshnessOverrides;
51
64
  }
52
65
  start() {
53
66
  if (this.intervalId) {
@@ -170,7 +183,8 @@ class StatusMonitor {
170
183
  tmux: this.tmux,
171
184
  paneObservation: this.paneObservation,
172
185
  claudeTranscript: this.claudeTranscript,
173
- interactiveCommands: this.interactiveCommands
186
+ interactiveCommands: this.interactiveCommands,
187
+ runtimeFreshnessWindowMs: this.runtimeFreshnessOverrides.get(worker.runtimeId)
174
188
  });
175
189
  if (!signals) {
176
190
  this.removeWorker(worker.id);
@@ -276,7 +290,8 @@ class StatusMonitor {
276
290
  if (this.traceMode !== "verbose") {
277
291
  return;
278
292
  }
279
- console.log(`[arcane-agents][status] poll workers=${timing.workerCount} duration=${Math.round(timing.durationMs)}ms ` +
293
+ const timestamp = new Date().toLocaleTimeString("en-AU", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
294
+ console.log(`[arcane-agents][status] ${timestamp} poll workers=${timing.workerCount} duration=${Math.round(timing.durationMs)}ms ` +
280
295
  `avgWorker=${Math.round(timing.averageWorkerDurationMs)}ms maxWorker=${Math.round(timing.maxWorkerDurationMs)}ms ` +
281
296
  `outcomes={updated:${timing.outcomeCounts.updated},unchanged:${timing.outcomeCounts.unchanged},removed:${timing.outcomeCounts.removed},failed:${timing.outcomeCounts.failed}}`);
282
297
  }
@@ -305,8 +320,11 @@ class StatusMonitor {
305
320
  `outQuiet=${Math.round(evaluation.facts.outputQuietForMs)}ms ` +
306
321
  `cmdQuiet=${Math.round(evaluation.facts.commandQuietForMs)}ms ` +
307
322
  `claude=${evaluation.facts.isClaudeSession ? 1 : 0} ` +
308
- `opencode=${evaluation.facts.isOpenCodeSession ? 1 : 0}`;
309
- console.log(`[arcane-agents][status] ${worker.displayName ?? worker.name} ${fromTo} (${Math.round(evaluation.confidence * 100)}%)${activityText} reasons=[${reasonText}] ${traceFacts}`);
323
+ `opencode=${evaluation.facts.isOpenCodeSession ? 1 : 0} ` +
324
+ `codex=${evaluation.facts.isCodexSession ? 1 : 0} ` +
325
+ `runtimeProc=${evaluation.facts.hasActiveRuntimeProcess ? 1 : 0}`;
326
+ const timestamp = new Date().toLocaleTimeString("en-AU", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
327
+ console.log(`[arcane-agents][status] ${timestamp} ${worker.displayName ?? worker.name} ${fromTo} (${Math.round(evaluation.confidence * 100)}%)${activityText} reasons=[${reasonText}] ${traceFacts}`);
310
328
  }
311
329
  recordStatusTransition(worker, evaluation) {
312
330
  if (evaluation.status === worker.status) {
@@ -8,7 +8,7 @@ vitest_1.vi.mock("./statusPipeline", () => ({
8
8
  evaluateWorkerStatusSignals: vitest_1.vi.fn(),
9
9
  normalizeWorkerStatusEvaluation: vitest_1.vi.fn((evaluation) => evaluation)
10
10
  }));
11
- const testConfig = { status: { interactiveCommands: [] } };
11
+ const testConfig = { status: { interactiveCommands: [] }, runtimes: {} };
12
12
  const defaultFacts = {
13
13
  command: "claude",
14
14
  commandQuietForMs: 0,
@@ -16,10 +16,15 @@ const defaultFacts = {
16
16
  workerAgeMs: 0,
17
17
  isClaudeSession: true,
18
18
  isOpenCodeSession: false,
19
+ isCodexSession: false,
20
+ hasClaudePromptSignal: false,
19
21
  hasOpenCodePromptSignal: false,
20
22
  hasOpenCodeActiveSignal: false,
23
+ hasCodexPromptSignal: false,
24
+ hasCodexActiveSignal: false,
21
25
  hasClaudeProgressSignal: false,
22
26
  hasActiveClaudeTask: false,
27
+ hasActiveRuntimeProcess: false,
23
28
  hasRuntimeActivityText: false,
24
29
  hasParsedStrongSignal: false,
25
30
  hasParsedNeedsInput: false,
@@ -90,7 +95,9 @@ function createSignals() {
90
95
  lastOutputChangeAtMs: Date.now()
91
96
  },
92
97
  transcriptSnapshot: undefined,
93
- interactiveCommands: new Set()
98
+ runtimeProcess: undefined,
99
+ interactiveCommands: new Set(),
100
+ runtimeFreshnessWindowMs: undefined
94
101
  };
95
102
  }
96
103
  function createEvaluation(status) {
@@ -4,15 +4,21 @@ exports.collectWorkerStatusSignals = collectWorkerStatusSignals;
4
4
  exports.evaluateWorkerStatusSignals = evaluateWorkerStatusSignals;
5
5
  exports.normalizeWorkerStatusEvaluation = normalizeWorkerStatusEvaluation;
6
6
  const runtimeSignals_1 = require("./runtimeSignals");
7
+ const runtimeProcess_1 = require("./runtime/runtimeProcess");
7
8
  const statusEvaluator_1 = require("./statusEvaluator");
8
9
  const paneObservation_1 = require("./paneObservation");
9
- async function collectWorkerStatusSignals({ worker, tmux, paneObservation, claudeTranscript, interactiveCommands }) {
10
+ async function collectWorkerStatusSignals({ worker, tmux, paneObservation, claudeTranscript, interactiveCommands, runtimeFreshnessWindowMs }) {
10
11
  const paneState = await tmux.getPaneState(worker.tmuxRef);
11
12
  if (paneState.isDead) {
12
13
  return undefined;
13
14
  }
15
+ const currentCommandLower = paneState.currentCommand.toLowerCase();
16
+ const runtimeProcess = paneState.panePid && (currentCommandLower === "bash" || currentCommandLower === "zsh" || currentCommandLower === "sh")
17
+ ? await (0, runtimeProcess_1.findAgentRuntimeProcess)(paneState.panePid)
18
+ : undefined;
19
+ const captureCommand = runtimeProcess?.runtime ?? currentCommandLower;
14
20
  const [output, transcriptSnapshot] = await Promise.all([
15
- tmux.capturePane(worker.tmuxRef, (0, runtimeSignals_1.capturePaneLineCount)(worker, paneState.currentCommand.toLowerCase())),
21
+ tmux.capturePane(worker.tmuxRef, (0, runtimeSignals_1.capturePaneLineCount)(worker, captureCommand)),
16
22
  claudeTranscript.poll(worker, paneState.currentCommand, paneState.currentPath, paneState.panePid)
17
23
  ]);
18
24
  const observation = (0, paneObservation_1.observePane)(paneObservation, worker.id, paneState.currentCommand, output);
@@ -21,7 +27,9 @@ async function collectWorkerStatusSignals({ worker, tmux, paneObservation, claud
21
27
  output,
22
28
  observation,
23
29
  transcriptSnapshot,
24
- interactiveCommands
30
+ runtimeProcess,
31
+ interactiveCommands,
32
+ runtimeFreshnessWindowMs
25
33
  };
26
34
  }
27
35
  function evaluateWorkerStatusSignals(worker, signals) {
@@ -31,7 +39,9 @@ function evaluateWorkerStatusSignals(worker, signals) {
31
39
  output: signals.output,
32
40
  observation: signals.observation,
33
41
  transcriptSnapshot: signals.transcriptSnapshot,
34
- interactiveCommands: signals.interactiveCommands
42
+ runtimeProcess: signals.runtimeProcess,
43
+ interactiveCommands: signals.interactiveCommands,
44
+ runtimeFreshnessWindowMs: signals.runtimeFreshnessWindowMs
35
45
  });
36
46
  }
37
47
  function normalizeWorkerStatusEvaluation(evaluation) {
@@ -1,17 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TmuxAdapter = void 0;
4
+ exports.clipboardCandidatesForEnvironment = clipboardCandidatesForEnvironment;
4
5
  const node_child_process_1 = require("node:child_process");
5
6
  const node_util_1 = require("node:util");
7
+ const tmuxClient_1 = require("./tmuxClient");
6
8
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
7
9
  class TmuxAdapter {
8
- sessionName;
9
- sessionClipboardConfigured = false;
10
- constructor(sessionName) {
11
- this.sessionName = sessionName;
10
+ config;
11
+ managedDefaultsConfigured = false;
12
+ constructor(config) {
13
+ this.config = config;
12
14
  }
13
15
  async spawnWorker(input) {
14
- const target = `${this.sessionName}:${input.windowName}`;
16
+ const target = `${this.config.sessionName}:${input.windowName}`;
15
17
  const commandLine = input.command.map(shellQuote).join(" ");
16
18
  const env = `ARCANE_AGENTS_WORKER_ID=${input.workerId}`;
17
19
  if (await this.hasSession()) {
@@ -19,7 +21,7 @@ class TmuxAdapter {
19
21
  "new-window",
20
22
  "-d",
21
23
  "-t",
22
- this.sessionName,
24
+ this.config.sessionName,
23
25
  "-n",
24
26
  input.windowName,
25
27
  "-c",
@@ -30,11 +32,12 @@ class TmuxAdapter {
30
32
  ]);
31
33
  }
32
34
  else {
35
+ this.managedDefaultsConfigured = false;
33
36
  await this.runTmux([
34
37
  "new-session",
35
38
  "-d",
36
39
  "-s",
37
- this.sessionName,
40
+ this.config.sessionName,
38
41
  "-n",
39
42
  input.windowName,
40
43
  "-c",
@@ -44,7 +47,7 @@ class TmuxAdapter {
44
47
  commandLine
45
48
  ]);
46
49
  }
47
- await this.ensureSessionClipboardDefaults();
50
+ await this.ensureManagedDefaults();
48
51
  await this.setWindowMetadata(target, {
49
52
  "@arcane_agents_managed": "1",
50
53
  "@arcane_agents_worker_id": input.workerId,
@@ -63,7 +66,7 @@ class TmuxAdapter {
63
66
  throw new Error(`Unable to resolve tmux pane for ${target}.`);
64
67
  }
65
68
  return {
66
- session: this.sessionName,
69
+ session: this.config.sessionName,
67
70
  window: input.windowName,
68
71
  pane
69
72
  };
@@ -75,23 +78,18 @@ class TmuxAdapter {
75
78
  }
76
79
  await this.stopGracefully(ref);
77
80
  }
78
- async ensureSessionClipboardDefaults() {
79
- if (this.sessionClipboardConfigured) {
81
+ async ensureManagedDefaults() {
82
+ if (this.managedDefaultsConfigured) {
80
83
  return;
81
84
  }
82
85
  if (!(await this.hasSession())) {
83
86
  return;
84
87
  }
85
88
  const copyCommand = await detectClipboardCopyCommand();
86
- if (!copyCommand) {
87
- this.sessionClipboardConfigured = true;
88
- return;
89
+ for (const command of (0, tmuxClient_1.buildFriendlyTmuxDefaults)({ copyCommand })) {
90
+ await this.runTmux(command).catch(() => undefined);
89
91
  }
90
- await Promise.all([
91
- this.runTmux(["set-option", "-t", this.sessionName, "set-clipboard", "external"]),
92
- this.runTmux(["set-option", "-t", this.sessionName, "copy-command", copyCommand])
93
- ]).catch(() => undefined);
94
- this.sessionClipboardConfigured = true;
92
+ this.managedDefaultsConfigured = true;
95
93
  }
96
94
  async windowExists(ref) {
97
95
  try {
@@ -160,7 +158,8 @@ class TmuxAdapter {
160
158
  throw error;
161
159
  }
162
160
  await new Promise((resolve, reject) => {
163
- const guardCommand = `tmux has-session -t ${shellQuote(externalSession)} >/dev/null 2>&1 || exit 0; exec tmux attach-session -t ${shellQuote(externalSession)}`;
161
+ const tmuxCommand = (0, tmuxClient_1.buildTmuxCommandPrefix)(this.config);
162
+ const guardCommand = `${tmuxCommand} has-session -t ${shellQuote(externalSession)} >/dev/null 2>&1 || exit 0; exec ${tmuxCommand} attach-session -t ${shellQuote(externalSession)}`;
164
163
  const child = (0, node_child_process_1.spawn)("xdg-terminal-exec", ["sh", "-lc", guardCommand], {
165
164
  detached: true,
166
165
  stdio: "ignore"
@@ -219,7 +218,7 @@ class TmuxAdapter {
219
218
  }
220
219
  async hasSession() {
221
220
  try {
222
- await this.runTmux(["has-session", "-t", this.sessionName]);
221
+ await this.runTmux(["has-session", "-t", this.config.sessionName]);
223
222
  return true;
224
223
  }
225
224
  catch {
@@ -230,7 +229,7 @@ class TmuxAdapter {
230
229
  return `${ref.session}:${ref.window}`;
231
230
  }
232
231
  async runTmux(args) {
233
- const { stdout } = await execFileAsync("tmux", args, {
232
+ const { stdout } = await execFileAsync("tmux", (0, tmuxClient_1.buildTmuxArgs)(args, this.config), {
234
233
  maxBuffer: 1024 * 1024
235
234
  });
236
235
  return stdout.trimEnd();
@@ -246,21 +245,6 @@ class TmuxAdapter {
246
245
  }
247
246
  await this.runTmux(["send-keys", "-t", target, "C-c"]).catch(() => undefined);
248
247
  await delay(220);
249
- const paneInfo = await this.runTmux([
250
- "list-panes",
251
- "-t",
252
- target,
253
- "-F",
254
- "#{pane_pid}\t#{pane_current_command}\t#{pane_dead}"
255
- ]).catch(() => "");
256
- const [panePidText = "", paneCommand = "", paneDeadFlag = "1"] = firstLine(paneInfo).split("\t");
257
- const panePid = Number.parseInt(panePidText, 10);
258
- const currentCommand = paneCommand.trim().toLowerCase();
259
- const paneDead = paneDeadFlag === "1";
260
- if (!paneDead && Number.isFinite(panePid) && panePid > 1 && currentCommand !== "bash" && currentCommand !== "zsh") {
261
- await terminateProcessGroup(panePid).catch(() => undefined);
262
- await delay(90);
263
- }
264
248
  await this.runTmux(["kill-window", "-t", target]).catch(() => undefined);
265
249
  }
266
250
  }
@@ -292,7 +276,7 @@ function normalizeOption(value) {
292
276
  return value;
293
277
  }
294
278
  async function detectClipboardCopyCommand() {
295
- const candidates = clipboardCandidatesForPlatform(process.platform);
279
+ const candidates = clipboardCandidatesForEnvironment(process.platform, process.env);
296
280
  for (const candidate of candidates) {
297
281
  if (await commandExists(candidate.binary)) {
298
282
  return candidate.command;
@@ -300,18 +284,22 @@ async function detectClipboardCopyCommand() {
300
284
  }
301
285
  return undefined;
302
286
  }
303
- function clipboardCandidatesForPlatform(platform) {
287
+ function clipboardCandidatesForEnvironment(platform, env = process.env) {
304
288
  if (platform === "darwin") {
305
289
  return [{ binary: "pbcopy", command: "pbcopy" }];
306
290
  }
307
291
  if (platform === "win32") {
308
292
  return [{ binary: "clip.exe", command: "clip.exe" }];
309
293
  }
310
- return [
294
+ const linuxCandidates = [
311
295
  { binary: "wl-copy", command: "wl-copy" },
312
296
  { binary: "xclip", command: "xclip -selection clipboard -in" },
313
297
  { binary: "xsel", command: "xsel --clipboard --input" }
314
298
  ];
299
+ if (platform === "linux" && isWslEnvironment(env)) {
300
+ return [{ binary: "clip.exe", command: "clip.exe" }, ...linuxCandidates];
301
+ }
302
+ return linuxCandidates;
315
303
  }
316
304
  async function commandExists(binary) {
317
305
  const locator = process.platform === "win32" ? "where" : "which";
@@ -325,15 +313,6 @@ async function commandExists(binary) {
325
313
  return false;
326
314
  }
327
315
  }
328
- async function terminateProcessGroup(panePid) {
329
- const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(panePid)], {
330
- maxBuffer: 1024 * 64
331
- });
332
- const pgid = Number.parseInt(stdout.trim(), 10);
333
- if (!Number.isFinite(pgid) || pgid <= 1) {
334
- return;
335
- }
336
- await execFileAsync("kill", ["-TERM", `-${pgid}`], { maxBuffer: 1024 * 64 }).catch(() => undefined);
337
- await delay(120);
338
- await execFileAsync("kill", ["-KILL", `-${pgid}`], { maxBuffer: 1024 * 64 }).catch(() => undefined);
316
+ function isWslEnvironment(env) {
317
+ return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP || env.WSLENV);
339
318
  }
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildTmuxArgs = buildTmuxArgs;
4
+ exports.buildTmuxAttachArgs = buildTmuxAttachArgs;
5
+ exports.buildTmuxCommandPrefix = buildTmuxCommandPrefix;
6
+ exports.buildFriendlyTmuxDefaults = buildFriendlyTmuxDefaults;
7
+ function buildTmuxArgs(args, options) {
8
+ return ["-L", options.socketName, ...args];
9
+ }
10
+ function buildTmuxAttachArgs(target, options) {
11
+ return buildTmuxArgs(["attach-session", "-t", target], options);
12
+ }
13
+ function buildTmuxCommandPrefix(options) {
14
+ return `tmux -L ${shellQuote(options.socketName)}`;
15
+ }
16
+ function buildFriendlyTmuxDefaults(options = {}) {
17
+ const copyAction = options.copyCommand ? "copy-pipe-and-cancel" : "copy-selection-and-cancel";
18
+ const commands = [
19
+ ["set-option", "-g", "mouse", "on"],
20
+ ["set-option", "-s", "escape-time", "0"],
21
+ ["set-window-option", "-g", "history-limit", "100000"],
22
+ ["bind-key", "-T", "copy-mode", "MouseDragEnd1Pane", "send-keys", "-X", copyAction],
23
+ ["bind-key", "-T", "copy-mode-vi", "MouseDragEnd1Pane", "send-keys", "-X", copyAction]
24
+ ];
25
+ if (options.copyCommand) {
26
+ commands.splice(3, 0, ["set-option", "-s", "set-clipboard", "external"], ["set-option", "-s", "copy-command", options.copyCommand]);
27
+ }
28
+ return commands;
29
+ }
30
+ function shellQuote(value) {
31
+ if (value.length === 0) {
32
+ return "''";
33
+ }
34
+ return `'${value.replace(/'/g, `'\\''`)}'`;
35
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const tmuxClient_1 = require("./tmuxClient");
5
+ const tmuxAdapter_1 = require("./tmuxAdapter");
6
+ (0, vitest_1.describe)("tmuxClient", () => {
7
+ (0, vitest_1.it)("prefixes tmux commands with the managed socket name", () => {
8
+ (0, vitest_1.expect)((0, tmuxClient_1.buildTmuxArgs)(["list-sessions"], { socketName: "arcane-agents" })).toEqual([
9
+ "-L",
10
+ "arcane-agents",
11
+ "list-sessions"
12
+ ]);
13
+ });
14
+ (0, vitest_1.it)("builds attach-session commands on the managed socket", () => {
15
+ (0, vitest_1.expect)((0, tmuxClient_1.buildTmuxAttachArgs)("arcane-agents:worker-1", { socketName: "arcane-agents" })).toEqual([
16
+ "-L",
17
+ "arcane-agents",
18
+ "attach-session",
19
+ "-t",
20
+ "arcane-agents:worker-1"
21
+ ]);
22
+ });
23
+ (0, vitest_1.it)("builds a shell-safe tmux command prefix", () => {
24
+ (0, vitest_1.expect)((0, tmuxClient_1.buildTmuxCommandPrefix)({ socketName: "arcane-agents-demo" })).toBe("tmux -L 'arcane-agents-demo'");
25
+ });
26
+ (0, vitest_1.it)("enables friendly defaults with clipboard-aware copy bindings when a copy command is available", () => {
27
+ (0, vitest_1.expect)((0, tmuxClient_1.buildFriendlyTmuxDefaults)({ copyCommand: "wl-copy" })).toEqual([
28
+ ["set-option", "-g", "mouse", "on"],
29
+ ["set-option", "-s", "escape-time", "0"],
30
+ ["set-window-option", "-g", "history-limit", "100000"],
31
+ ["set-option", "-s", "set-clipboard", "external"],
32
+ ["set-option", "-s", "copy-command", "wl-copy"],
33
+ ["bind-key", "-T", "copy-mode", "MouseDragEnd1Pane", "send-keys", "-X", "copy-pipe-and-cancel"],
34
+ ["bind-key", "-T", "copy-mode-vi", "MouseDragEnd1Pane", "send-keys", "-X", "copy-pipe-and-cancel"]
35
+ ]);
36
+ });
37
+ (0, vitest_1.it)("falls back to tmux buffer copy bindings when no clipboard command is available", () => {
38
+ (0, vitest_1.expect)((0, tmuxClient_1.buildFriendlyTmuxDefaults)()).toEqual([
39
+ ["set-option", "-g", "mouse", "on"],
40
+ ["set-option", "-s", "escape-time", "0"],
41
+ ["set-window-option", "-g", "history-limit", "100000"],
42
+ ["bind-key", "-T", "copy-mode", "MouseDragEnd1Pane", "send-keys", "-X", "copy-selection-and-cancel"],
43
+ ["bind-key", "-T", "copy-mode-vi", "MouseDragEnd1Pane", "send-keys", "-X", "copy-selection-and-cancel"]
44
+ ]);
45
+ });
46
+ (0, vitest_1.it)("prefers the Windows clipboard bridge when running inside WSL", () => {
47
+ (0, vitest_1.expect)((0, tmuxAdapter_1.clipboardCandidatesForEnvironment)("linux", { WSL_DISTRO_NAME: "Ubuntu" })[0]).toEqual({
48
+ binary: "clip.exe",
49
+ command: "clip.exe"
50
+ });
51
+ });
52
+ (0, vitest_1.it)("keeps native Linux clipboard commands first outside WSL", () => {
53
+ (0, vitest_1.expect)((0, tmuxAdapter_1.clipboardCandidatesForEnvironment)("linux", {})[0]).toEqual({
54
+ binary: "wl-copy",
55
+ command: "wl-copy"
56
+ });
57
+ });
58
+ });