@agentmeshhq/agent 0.1.13 → 0.1.15

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 (89) hide show
  1. package/dist/__tests__/loader.test.js +44 -1
  2. package/dist/__tests__/loader.test.js.map +1 -1
  3. package/dist/__tests__/runner.test.js.map +1 -1
  4. package/dist/__tests__/sandbox.test.js.map +1 -1
  5. package/dist/__tests__/watchdog.test.d.ts +1 -0
  6. package/dist/__tests__/watchdog.test.js +290 -0
  7. package/dist/__tests__/watchdog.test.js.map +1 -0
  8. package/dist/cli/attach.js +20 -1
  9. package/dist/cli/attach.js.map +1 -1
  10. package/dist/cli/build.js +8 -2
  11. package/dist/cli/build.js.map +1 -1
  12. package/dist/cli/context.js.map +1 -1
  13. package/dist/cli/deploy.js +1 -1
  14. package/dist/cli/deploy.js.map +1 -1
  15. package/dist/cli/init.js +1 -1
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/list.js +3 -3
  18. package/dist/cli/list.js.map +1 -1
  19. package/dist/cli/local.js +5 -3
  20. package/dist/cli/local.js.map +1 -1
  21. package/dist/cli/migrate.js +1 -1
  22. package/dist/cli/migrate.js.map +1 -1
  23. package/dist/cli/nudge.js +16 -3
  24. package/dist/cli/nudge.js.map +1 -1
  25. package/dist/cli/restart.js.map +1 -1
  26. package/dist/cli/slack.js +1 -1
  27. package/dist/cli/slack.js.map +1 -1
  28. package/dist/cli/stop.js +13 -5
  29. package/dist/cli/stop.js.map +1 -1
  30. package/dist/cli/test.js +1 -1
  31. package/dist/cli/test.js.map +1 -1
  32. package/dist/cli/token.js +2 -2
  33. package/dist/cli/token.js.map +1 -1
  34. package/dist/config/loader.d.ts +5 -1
  35. package/dist/config/loader.js +27 -2
  36. package/dist/config/loader.js.map +1 -1
  37. package/dist/config/schema.d.ts +13 -0
  38. package/dist/core/daemon.d.ts +32 -1
  39. package/dist/core/daemon.js +395 -19
  40. package/dist/core/daemon.js.map +1 -1
  41. package/dist/core/injector.d.ts +2 -2
  42. package/dist/core/injector.js +23 -4
  43. package/dist/core/injector.js.map +1 -1
  44. package/dist/core/runner.d.ts +1 -1
  45. package/dist/core/runner.js +44 -1
  46. package/dist/core/runner.js.map +1 -1
  47. package/dist/core/sandbox.d.ts +11 -0
  48. package/dist/core/sandbox.js +34 -2
  49. package/dist/core/sandbox.js.map +1 -1
  50. package/dist/core/tmux.d.ts +9 -0
  51. package/dist/core/tmux.js +105 -11
  52. package/dist/core/tmux.js.map +1 -1
  53. package/dist/core/watchdog.d.ts +41 -0
  54. package/dist/core/watchdog.js +198 -0
  55. package/dist/core/watchdog.js.map +1 -0
  56. package/dist/core/websocket.js +1 -1
  57. package/dist/core/websocket.js.map +1 -1
  58. package/dist/index.d.ts +5 -5
  59. package/dist/index.js +5 -5
  60. package/dist/index.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/loader.test.ts +52 -4
  63. package/src/__tests__/runner.test.ts +1 -2
  64. package/src/__tests__/sandbox.test.ts +1 -1
  65. package/src/__tests__/watchdog.test.ts +368 -0
  66. package/src/cli/attach.ts +22 -1
  67. package/src/cli/build.ts +12 -4
  68. package/src/cli/context.ts +0 -1
  69. package/src/cli/deploy.ts +7 -5
  70. package/src/cli/init.ts +7 -19
  71. package/src/cli/list.ts +6 -10
  72. package/src/cli/local.ts +21 -12
  73. package/src/cli/migrate.ts +6 -4
  74. package/src/cli/nudge.ts +29 -14
  75. package/src/cli/restart.ts +1 -1
  76. package/src/cli/slack.ts +16 -15
  77. package/src/cli/stop.ts +14 -5
  78. package/src/cli/test.ts +5 -3
  79. package/src/cli/token.ts +4 -4
  80. package/src/config/loader.ts +29 -2
  81. package/src/config/schema.ts +14 -0
  82. package/src/core/daemon.ts +474 -24
  83. package/src/core/injector.ts +27 -4
  84. package/src/core/runner.ts +49 -1
  85. package/src/core/sandbox.ts +47 -2
  86. package/src/core/tmux.ts +135 -12
  87. package/src/core/watchdog.ts +238 -0
  88. package/src/core/websocket.ts +2 -2
  89. package/src/index.ts +6 -5
@@ -6,15 +6,38 @@ import type { InboxItem } from "./registry.js";
6
6
  import { sendKeys } from "./tmux.js";
7
7
  import type { WebSocketEvent } from "./websocket.js";
8
8
 
9
- export function injectStartupMessage(agentName: string, pendingCount: number): void {
9
+ export function injectStartupMessage(
10
+ agentName: string,
11
+ pendingCount: number,
12
+ inboxItems?: InboxItem[],
13
+ ): void {
10
14
  if (pendingCount === 0) {
11
15
  const message = `[AgentMesh] Connected and ready. No pending items in inbox.`;
12
16
  sendKeys(agentName, message);
13
17
  return;
14
18
  }
15
19
 
16
- const message = `[AgentMesh] Welcome back! You have ${pendingCount} pending handoff${pendingCount === 1 ? "" : "s"} in your inbox.
17
- Use agentmesh_check_inbox to see them, or agentmesh_accept_handoff to start working.`;
20
+ // Build detailed message with handoff info
21
+ let message = `[AgentMesh] Welcome back! You have ${pendingCount} pending handoff${pendingCount === 1 ? "" : "s"}.`;
22
+
23
+ if (inboxItems && inboxItems.length > 0) {
24
+ message += "\n\n--- PENDING HANDOFFS ---";
25
+ for (const item of inboxItems.slice(0, 3)) {
26
+ // Show up to 3
27
+ const fromName = item.from_agent?.display_name || item.from_agent_id || "Unknown";
28
+ message += `\n\nHandoff ID: ${item.id}`;
29
+ message += `\nFrom: ${fromName}`;
30
+ message += `\nScope: ${item.scope || "Not specified"}`;
31
+ message += `\nReason: ${item.reason || "Not specified"}`;
32
+ }
33
+ if (inboxItems.length > 3) {
34
+ message += `\n\n... and ${inboxItems.length - 3} more.`;
35
+ }
36
+ message += "\n\n--- END HANDOFFS ---";
37
+ message += "\n\nReview the handoff(s) above and begin work on the assigned task.";
38
+ } else {
39
+ message += "\nUse agentmesh_check_inbox to see them.";
40
+ }
18
41
 
19
42
  sendKeys(agentName, message);
20
43
  }
@@ -84,7 +107,7 @@ interface SlackFile {
84
107
  export function injectSlackMessage(
85
108
  agentName: string,
86
109
  event: WebSocketEvent,
87
- context?: EventContext,
110
+ _context?: EventContext,
88
111
  ): void {
89
112
  const user = (event.user as string) || "unknown";
90
113
  const channel = (event.channel as string) || "unknown";
@@ -120,11 +120,59 @@ export function validateOpenCodeModel(model: string): { valid: boolean; error?:
120
120
  };
121
121
  }
122
122
 
123
+ // Common model aliases to their full OpenCode names
124
+ const MODEL_ALIASES: Record<string, string> = {
125
+ // Anthropic — Claude
126
+ "claude-sonnet-4": "anthropic/claude-sonnet-4-5",
127
+ "claude-sonnet-4-0": "anthropic/claude-sonnet-4-0",
128
+ "claude-sonnet-4-5": "anthropic/claude-sonnet-4-5",
129
+ "claude-sonnet-4-6": "anthropic/claude-sonnet-4-6",
130
+ "claude-opus-4": "anthropic/claude-opus-4-5",
131
+ "claude-opus-4-0": "anthropic/claude-opus-4-0",
132
+ "claude-opus-4-1": "anthropic/claude-opus-4-1",
133
+ "claude-opus-4-5": "anthropic/claude-opus-4-5",
134
+ "claude-opus-4-6": "anthropic/claude-opus-4-6",
135
+ "claude-haiku-4": "anthropic/claude-haiku-4-5",
136
+ "claude-haiku-4-5": "anthropic/claude-haiku-4-5",
137
+ "claude-3-5-sonnet": "anthropic/claude-3-5-sonnet-20241022",
138
+ "claude-3-5-haiku": "anthropic/claude-3-5-haiku-latest",
139
+ "claude-3-7-sonnet": "anthropic/claude-3-7-sonnet-latest",
140
+
141
+ // OpenAI — GPT & Codex
142
+ "gpt-5.2": "openai/gpt-5.2",
143
+ "gpt-5.3-codex": "openai/gpt-5.3-codex",
144
+ "gpt-5.2-codex": "openai/gpt-5.2-codex",
145
+ "gpt-5.1-codex": "openai/gpt-5.1-codex",
146
+ "gpt-5.1-codex-max": "openai/gpt-5.1-codex-max",
147
+ "gpt-5.1-codex-mini": "openai/gpt-5.1-codex-mini",
148
+ "gpt-5-codex": "openai/gpt-5-codex",
149
+ "codex-mini": "openai/codex-mini-latest",
150
+ codex: "openai/gpt-5.3-codex",
151
+
152
+ // xAI — Grok
153
+ "grok-4": "xai/grok-4",
154
+ "grok-4-fast": "xai/grok-4-fast",
155
+ "grok-3": "xai/grok-3",
156
+ "grok-3-fast": "xai/grok-3-fast",
157
+ "grok-3-mini": "xai/grok-3-mini",
158
+ "grok-code": "xai/grok-code-fast-1",
159
+ };
160
+
123
161
  /**
124
162
  * Normalizes a model name for OpenCode
125
- * e.g., "claude-sonnet-4" -> "anthropic/claude-sonnet-4" if that's what OpenCode expects
163
+ * e.g., "claude-sonnet-4" -> "anthropic/claude-sonnet-4-5" if that's what OpenCode expects
126
164
  */
127
165
  export function normalizeOpenCodeModel(model: string): string {
166
+ // Check hardcoded aliases first (works even if opencode not installed)
167
+ if (MODEL_ALIASES[model]) {
168
+ return MODEL_ALIASES[model];
169
+ }
170
+
171
+ // Already has provider prefix
172
+ if (model.includes("/")) {
173
+ return model;
174
+ }
175
+
128
176
  const models = getOpenCodeModels();
129
177
 
130
178
  // Direct match
@@ -34,6 +34,8 @@ export interface SandboxConfig {
34
34
  serveMode?: boolean;
35
35
  /** Port for serve mode */
36
36
  servePort?: number;
37
+ /** Custom command to run in container (overrides default) */
38
+ command?: string[];
37
39
  }
38
40
 
39
41
  export interface SandboxMountPolicy {
@@ -223,8 +225,11 @@ export class DockerSandbox {
223
225
  // Image and command
224
226
  args.push(this.config.image);
225
227
 
226
- // Command: either serve mode or keep container alive for tmux-style attach
227
- if (this.config.serveMode) {
228
+ // Command: custom command > serve mode > tail
229
+ if (this.config.command && this.config.command.length > 0) {
230
+ // Custom command (e.g., agentmesh start inside container)
231
+ args.push(...this.config.command);
232
+ } else if (this.config.serveMode) {
228
233
  args.push(
229
234
  "opencode",
230
235
  "serve",
@@ -433,6 +438,46 @@ export class DockerSandbox {
433
438
  this.containerId = null;
434
439
  }
435
440
 
441
+ /**
442
+ * Restarts the container (destroy + start)
443
+ * Returns the new container ID
444
+ */
445
+ async restart(): Promise<string> {
446
+ await this.destroy();
447
+
448
+ // Generate a new container name
449
+ const suffix = crypto.randomBytes(4).toString("hex");
450
+ this.containerName = `agentmesh-sandbox-${this.config.agentName}-${suffix}`;
451
+
452
+ return this.start();
453
+ }
454
+
455
+ /**
456
+ * Force removes any existing containers for this agent
457
+ */
458
+ static forceCleanup(agentName: string): number {
459
+ const result = spawnSync(
460
+ "docker",
461
+ ["ps", "-aq", "--filter", `name=agentmesh-sandbox-${agentName}-`],
462
+ { encoding: "utf-8" },
463
+ );
464
+
465
+ if (result.status !== 0 || !result.stdout.trim()) {
466
+ return 0;
467
+ }
468
+
469
+ const containers = result.stdout.trim().split("\n").filter(Boolean);
470
+
471
+ for (const containerId of containers) {
472
+ spawnSync("docker", ["rm", "-f", containerId], {
473
+ encoding: "utf-8",
474
+ timeout: 10000,
475
+ });
476
+ }
477
+
478
+ return containers.length;
479
+ }
480
+
436
481
  /**
437
482
  * Gets container name
438
483
  */
package/src/core/tmux.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { type ChildProcess, execFileSync, execSync, spawn } from "node:child_process";
1
+ import { execFileSync, execSync, spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
 
3
6
  const SESSION_PREFIX = "agentmesh-";
4
7
 
@@ -37,24 +40,59 @@ export function createSession(
37
40
  }
38
41
 
39
42
  try {
43
+ // Isolate OpenCode's SQLite database per agent to prevent WAL corruption
44
+ // from multiple concurrent processes sharing one opencode.db file.
45
+ // See docs/RCA-OPENCODE-SQLITE-CORRUPTION.md for details.
46
+ const agentDataDir = path.join(os.homedir(), ".agentmesh", "opencode-data", agentName);
47
+ const agentOpencodeDir = path.join(agentDataDir, "opencode");
48
+ if (!fs.existsSync(agentOpencodeDir)) {
49
+ fs.mkdirSync(agentOpencodeDir, { recursive: true });
50
+ }
51
+
52
+ // Copy auth.json from default OpenCode data dir so agents inherit API keys.
53
+ // Strips xAI provider to prevent OpenCode from defaulting to non-Anthropic models.
54
+ const agentAuthPath = path.join(agentOpencodeDir, "auth.json");
55
+ const sourceAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
56
+ if (!fs.existsSync(agentAuthPath) && fs.existsSync(sourceAuthPath)) {
57
+ try {
58
+ const auth = JSON.parse(fs.readFileSync(sourceAuthPath, "utf-8"));
59
+ delete auth.xai;
60
+ fs.writeFileSync(agentAuthPath, JSON.stringify(auth, null, 2));
61
+ } catch {
62
+ // Non-fatal — agent will just need manual auth
63
+ }
64
+ }
65
+
40
66
  // Build environment prefix for the command
41
67
  // This ensures env vars are set BEFORE the process starts
68
+ const mergedEnv: SessionEnv = {
69
+ XDG_DATA_HOME: agentDataDir,
70
+ ...env,
71
+ };
72
+
42
73
  let envPrefix = "";
43
- if (env) {
44
- const envParts: string[] = [];
45
- for (const [key, value] of Object.entries(env)) {
46
- if (value !== undefined && value !== "") {
47
- // Escape special characters in value
48
- const escapedValue = value.replace(/"/g, '\\"');
49
- envParts.push(`${key}="${escapedValue}"`);
50
- }
74
+ const envParts: string[] = [];
75
+ for (const [key, value] of Object.entries(mergedEnv)) {
76
+ if (value !== undefined && value !== "") {
77
+ // Escape special characters in value
78
+ const escapedValue = value.replace(/"/g, '\\"');
79
+ envParts.push(`${key}="${escapedValue}"`);
51
80
  }
52
- if (envParts.length > 0) {
53
- envPrefix = envParts.join(" ") + " ";
81
+ }
82
+ if (envParts.length > 0) {
83
+ envPrefix = `${envParts.join(" ")} `;
84
+ }
85
+
86
+ // Append --model flag for opencode if OPENCODE_MODEL is set
87
+ let finalCommand = command;
88
+ if (env?.OPENCODE_MODEL && (command === "opencode" || command.startsWith("opencode "))) {
89
+ // Check if --model is already in the command
90
+ if (!command.includes("--model") && !command.includes("-m ")) {
91
+ finalCommand = `${command} --model ${env.OPENCODE_MODEL}`;
54
92
  }
55
93
  }
56
94
 
57
- const fullCommand = `${envPrefix}${command}`;
95
+ const fullCommand = `${envPrefix}${finalCommand}`;
58
96
 
59
97
  // Set reasonable terminal size for TUI applications
60
98
  const args = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
@@ -194,6 +232,91 @@ export function getSessionInfo(agentName: string): { exists: boolean; command?:
194
232
  }
195
233
  }
196
234
 
235
+ /**
236
+ * Checks if the session process is healthy (not crashed/exited)
237
+ * Returns true if healthy, false if crashed or dead
238
+ * @param containerName - If provided, checks inside the Docker container
239
+ */
240
+ export function isSessionHealthy(
241
+ agentName: string,
242
+ containerName?: string,
243
+ ): { healthy: boolean; reason?: string } {
244
+ const sessionName = getSessionName(agentName);
245
+
246
+ // For sandbox mode, check tmux inside the container
247
+ if (containerName) {
248
+ try {
249
+ // Check if container is running
250
+ const containerStatus = execSync(
251
+ `docker inspect -f '{{.State.Running}}' "${containerName}"`,
252
+ {
253
+ encoding: "utf-8",
254
+ },
255
+ ).trim();
256
+
257
+ if (containerStatus !== "true") {
258
+ return { healthy: false, reason: "container_not_running" };
259
+ }
260
+
261
+ // Check if tmux session exists inside container
262
+ const tmuxCheck = execSync(
263
+ `docker exec "${containerName}" tmux has-session -t "${sessionName}" 2>&1 || echo "no_session"`,
264
+ { encoding: "utf-8" },
265
+ ).trim();
266
+
267
+ if (tmuxCheck === "no_session" || tmuxCheck.includes("no server running")) {
268
+ return { healthy: false, reason: "session_not_found_in_container" };
269
+ }
270
+
271
+ // Check if pane is alive inside container
272
+ const paneInfo = execSync(
273
+ `docker exec "${containerName}" tmux list-panes -t "${sessionName}" -F "#{pane_pid}:#{pane_dead}"`,
274
+ { encoding: "utf-8" },
275
+ ).trim();
276
+
277
+ const [pid, dead] = paneInfo.split(":");
278
+
279
+ if (dead === "1") {
280
+ return { healthy: false, reason: "pane_dead_in_container" };
281
+ }
282
+
283
+ if (!pid || pid === "0") {
284
+ return { healthy: false, reason: "no_pid_in_container" };
285
+ }
286
+
287
+ return { healthy: true };
288
+ } catch (error) {
289
+ return { healthy: false, reason: `container_check_failed: ${error}` };
290
+ }
291
+ }
292
+
293
+ // Local mode - check tmux on host
294
+ if (!sessionExists(sessionName)) {
295
+ return { healthy: false, reason: "session_not_found" };
296
+ }
297
+
298
+ try {
299
+ // Check if pane is still alive and has a running process
300
+ const paneInfo = execSync(`tmux list-panes -t "${sessionName}" -F "#{pane_pid}:#{pane_dead}"`, {
301
+ encoding: "utf-8",
302
+ }).trim();
303
+
304
+ const [pid, dead] = paneInfo.split(":");
305
+
306
+ if (dead === "1") {
307
+ return { healthy: false, reason: "pane_dead" };
308
+ }
309
+
310
+ if (!pid || pid === "0") {
311
+ return { healthy: false, reason: "no_pid" };
312
+ }
313
+
314
+ return { healthy: true };
315
+ } catch (error) {
316
+ return { healthy: false, reason: `check_failed: ${error}` };
317
+ }
318
+ }
319
+
197
320
  export interface SessionContext {
198
321
  workdir: string;
199
322
  gitBranch?: string;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Agent Progress Watchdog
3
+ *
4
+ * Monitors agent activity and detects stuck agents.
5
+ * Checks OpenCode logs for activity and tmux pane for permission prompts.
6
+ */
7
+
8
+ import { execSync, spawnSync } from "node:child_process";
9
+ import { captureSessionOutput } from "./tmux.js";
10
+
11
+ export type WatchdogStatus = "active" | "idle" | "stuck" | "permission_blocked";
12
+
13
+ export interface WatchdogResult {
14
+ status: WatchdogStatus;
15
+ lastActivity?: Date;
16
+ blockedOn?: string;
17
+ details?: string;
18
+ }
19
+
20
+ // Patterns that indicate a permission prompt is blocking the agent
21
+ const PERMISSION_PROMPT_PATTERNS = [
22
+ "Permission required",
23
+ "Allow once",
24
+ "Allow always",
25
+ "Reject",
26
+ "Access external directory",
27
+ "△ Permission required",
28
+ ];
29
+
30
+ // Idle threshold: 2 minutes
31
+ const IDLE_THRESHOLD_MS = 2 * 60 * 1000;
32
+ // Stuck threshold: 5 minutes
33
+ const STUCK_THRESHOLD_MS = 5 * 60 * 1000;
34
+
35
+ /**
36
+ * Check agent progress by analyzing OpenCode logs and tmux output
37
+ */
38
+ export function checkAgentProgress(agentName: string, containerName?: string): WatchdogResult {
39
+ // First check for permission prompts (highest priority)
40
+ const permissionPrompt = detectPermissionPrompt(agentName);
41
+ if (permissionPrompt) {
42
+ return {
43
+ status: "permission_blocked",
44
+ blockedOn: permissionPrompt,
45
+ details: "Agent is waiting for permission approval",
46
+ };
47
+ }
48
+
49
+ // Check last activity from OpenCode logs
50
+ const lastActivity = getLastActivityTime(agentName, containerName);
51
+
52
+ if (!lastActivity) {
53
+ // Can't determine activity, assume active
54
+ return {
55
+ status: "active",
56
+ details: "Unable to determine activity time",
57
+ };
58
+ }
59
+
60
+ const timeSinceActivity = Date.now() - lastActivity.getTime();
61
+
62
+ if (timeSinceActivity > STUCK_THRESHOLD_MS) {
63
+ return {
64
+ status: "stuck",
65
+ lastActivity,
66
+ details: `No activity for ${Math.round(timeSinceActivity / 60000)} minutes`,
67
+ };
68
+ }
69
+
70
+ if (timeSinceActivity > IDLE_THRESHOLD_MS) {
71
+ return {
72
+ status: "idle",
73
+ lastActivity,
74
+ details: `Idle for ${Math.round(timeSinceActivity / 60000)} minutes`,
75
+ };
76
+ }
77
+
78
+ return {
79
+ status: "active",
80
+ lastActivity,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Detect if a permission prompt is blocking the agent
86
+ */
87
+ export function detectPermissionPrompt(agentName: string): string | null {
88
+ try {
89
+ const output = captureSessionOutput(agentName, 50);
90
+ if (!output) return null;
91
+
92
+ for (const pattern of PERMISSION_PROMPT_PATTERNS) {
93
+ if (output.includes(pattern)) {
94
+ // Try to extract what permission is being requested
95
+ const lines = output.split("\n");
96
+ for (const line of lines) {
97
+ if (line.includes("Access external directory")) {
98
+ const match = line.match(/Access external directory\s+(\S+)/);
99
+ if (match) return `External directory: ${match[1]}`;
100
+ }
101
+ if (line.includes("Permission required")) {
102
+ return "Permission prompt detected";
103
+ }
104
+ }
105
+ return "Permission prompt detected";
106
+ }
107
+ }
108
+
109
+ return null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get the last activity time from OpenCode logs
117
+ */
118
+ export function getLastActivityTime(agentName: string, containerName?: string): Date | null {
119
+ try {
120
+ let logLine: string;
121
+
122
+ if (containerName) {
123
+ // Sandbox mode: read from container
124
+ const result = spawnSync(
125
+ "docker",
126
+ [
127
+ "exec",
128
+ containerName,
129
+ "sh",
130
+ "-c",
131
+ "ls -t /home/node/.local/share/opencode/log/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null",
132
+ ],
133
+ { encoding: "utf-8", timeout: 5000 },
134
+ );
135
+
136
+ if (result.status !== 0 || !result.stdout.trim()) {
137
+ return null;
138
+ }
139
+ logLine = result.stdout.trim();
140
+ } else {
141
+ // Non-sandbox mode: read from local logs
142
+ const logDir = `${process.env.HOME}/.local/share/opencode/log`;
143
+ const result = spawnSync(
144
+ "sh",
145
+ ["-c", `ls -t ${logDir}/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null`],
146
+ { encoding: "utf-8", timeout: 5000 },
147
+ );
148
+
149
+ if (result.status !== 0 || !result.stdout.trim()) {
150
+ return null;
151
+ }
152
+ logLine = result.stdout.trim();
153
+ }
154
+
155
+ // Parse timestamp from log line
156
+ // Format: INFO 2026-02-26T00:14:42 +0ms service=...
157
+ const match = logLine.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/);
158
+ if (match) {
159
+ return new Date(match[1] + "Z");
160
+ }
161
+
162
+ return null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Send a nudge to the agent to continue working
170
+ */
171
+ export function sendNudge(agentName: string, message: string): boolean {
172
+ try {
173
+ const sessionName = `agentmesh-${agentName}`;
174
+
175
+ // Send keys to tmux session
176
+ execSync(`tmux send-keys -t "${sessionName}" "${message.replace(/"/g, '\\"')}" Enter`, {
177
+ encoding: "utf-8",
178
+ timeout: 5000,
179
+ });
180
+
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check if a process is actually running
189
+ */
190
+ export function isProcessRunning(pid: number): boolean {
191
+ try {
192
+ process.kill(pid, 0);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Find orphan containers for an agent
201
+ */
202
+ export function findOrphanContainers(agentName: string): string[] {
203
+ try {
204
+ const result = spawnSync(
205
+ "docker",
206
+ ["ps", "-aq", "--filter", `name=agentmesh-sandbox-${agentName}-`],
207
+ { encoding: "utf-8", timeout: 5000 },
208
+ );
209
+
210
+ if (result.status !== 0 || !result.stdout.trim()) {
211
+ return [];
212
+ }
213
+
214
+ return result.stdout.trim().split("\n").filter(Boolean);
215
+ } catch {
216
+ return [];
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Remove orphan containers for an agent
222
+ */
223
+ export function cleanupOrphanContainers(agentName: string): number {
224
+ const containers = findOrphanContainers(agentName);
225
+
226
+ for (const containerId of containers) {
227
+ try {
228
+ spawnSync("docker", ["rm", "-f", containerId], {
229
+ encoding: "utf-8",
230
+ timeout: 10000,
231
+ });
232
+ } catch {
233
+ // Ignore cleanup errors
234
+ }
235
+ }
236
+
237
+ return containers.length;
238
+ }
@@ -70,11 +70,11 @@ export class AgentWebSocket {
70
70
  }
71
71
 
72
72
  this.reconnectAttempts++;
73
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
73
+ const delay = this.reconnectDelay * 2 ** (this.reconnectAttempts - 1);
74
74
 
75
75
  setTimeout(() => {
76
76
  console.log(
77
- `Reconnecting... (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`
77
+ `Reconnecting... (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
78
78
  );
79
79
  this.connect();
80
80
  }, delay);
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // Re-export core modules for programmatic usage
2
+
3
+ export * from "./config/loader.js";
4
+ export * from "./config/schema.js";
2
5
  export { AgentDaemon } from "./core/daemon.js";
3
- export { AgentWebSocket } from "./core/websocket.js";
4
6
  export { Heartbeat } from "./core/heartbeat.js";
5
- export * from "./core/tmux.js";
6
- export * from "./core/registry.js";
7
7
  export * from "./core/injector.js";
8
- export * from "./config/schema.js";
9
- export * from "./config/loader.js";
8
+ export * from "./core/registry.js";
9
+ export * from "./core/tmux.js";
10
+ export { AgentWebSocket } from "./core/websocket.js";