@agentmeshhq/agent 0.1.12 → 0.1.14

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 (117) 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.d.ts +1 -0
  5. package/dist/__tests__/sandbox.test.js +362 -0
  6. package/dist/__tests__/sandbox.test.js.map +1 -0
  7. package/dist/__tests__/watchdog.test.d.ts +1 -0
  8. package/dist/__tests__/watchdog.test.js +290 -0
  9. package/dist/__tests__/watchdog.test.js.map +1 -0
  10. package/dist/cli/attach.js +20 -1
  11. package/dist/cli/attach.js.map +1 -1
  12. package/dist/cli/build.js +8 -2
  13. package/dist/cli/build.js.map +1 -1
  14. package/dist/cli/context.js.map +1 -1
  15. package/dist/cli/deploy.js +1 -1
  16. package/dist/cli/deploy.js.map +1 -1
  17. package/dist/cli/inbox.d.ts +5 -0
  18. package/dist/cli/inbox.js +123 -0
  19. package/dist/cli/inbox.js.map +1 -0
  20. package/dist/cli/index.js +5 -1
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/init.js +1 -1
  23. package/dist/cli/init.js.map +1 -1
  24. package/dist/cli/issue.d.ts +42 -0
  25. package/dist/cli/issue.js +297 -0
  26. package/dist/cli/issue.js.map +1 -0
  27. package/dist/cli/list.js +3 -3
  28. package/dist/cli/list.js.map +1 -1
  29. package/dist/cli/local.js +5 -3
  30. package/dist/cli/local.js.map +1 -1
  31. package/dist/cli/migrate.js +1 -1
  32. package/dist/cli/migrate.js.map +1 -1
  33. package/dist/cli/nudge.js +16 -3
  34. package/dist/cli/nudge.js.map +1 -1
  35. package/dist/cli/ready.d.ts +5 -0
  36. package/dist/cli/ready.js +131 -0
  37. package/dist/cli/ready.js.map +1 -0
  38. package/dist/cli/restart.js.map +1 -1
  39. package/dist/cli/slack.js +1 -1
  40. package/dist/cli/slack.js.map +1 -1
  41. package/dist/cli/start.d.ts +8 -0
  42. package/dist/cli/start.js +9 -0
  43. package/dist/cli/start.js.map +1 -1
  44. package/dist/cli/stop.js +13 -5
  45. package/dist/cli/stop.js.map +1 -1
  46. package/dist/cli/sync.d.ts +8 -0
  47. package/dist/cli/sync.js +154 -0
  48. package/dist/cli/sync.js.map +1 -0
  49. package/dist/cli/test.js +1 -1
  50. package/dist/cli/test.js.map +1 -1
  51. package/dist/cli/token.js +2 -2
  52. package/dist/cli/token.js.map +1 -1
  53. package/dist/config/loader.d.ts +5 -1
  54. package/dist/config/loader.js +27 -2
  55. package/dist/config/loader.js.map +1 -1
  56. package/dist/config/schema.d.ts +13 -0
  57. package/dist/core/daemon.d.ts +50 -0
  58. package/dist/core/daemon.js +445 -11
  59. package/dist/core/daemon.js.map +1 -1
  60. package/dist/core/injector.d.ts +2 -2
  61. package/dist/core/injector.js +23 -4
  62. package/dist/core/injector.js.map +1 -1
  63. package/dist/core/issue-cache.d.ts +44 -0
  64. package/dist/core/issue-cache.js +75 -0
  65. package/dist/core/issue-cache.js.map +1 -0
  66. package/dist/core/registry.d.ts +5 -0
  67. package/dist/core/registry.js +8 -1
  68. package/dist/core/registry.js.map +1 -1
  69. package/dist/core/runner.d.ts +1 -1
  70. package/dist/core/runner.js +23 -1
  71. package/dist/core/runner.js.map +1 -1
  72. package/dist/core/sandbox.d.ts +138 -0
  73. package/dist/core/sandbox.js +409 -0
  74. package/dist/core/sandbox.js.map +1 -0
  75. package/dist/core/tmux.d.ts +8 -0
  76. package/dist/core/tmux.js +28 -1
  77. package/dist/core/tmux.js.map +1 -1
  78. package/dist/core/watchdog.d.ts +41 -0
  79. package/dist/core/watchdog.js +198 -0
  80. package/dist/core/watchdog.js.map +1 -0
  81. package/dist/core/websocket.js +1 -1
  82. package/dist/core/websocket.js.map +1 -1
  83. package/dist/index.d.ts +5 -5
  84. package/dist/index.js +5 -5
  85. package/dist/index.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/__tests__/loader.test.ts +52 -4
  88. package/src/__tests__/runner.test.ts +1 -2
  89. package/src/__tests__/sandbox.test.ts +435 -0
  90. package/src/__tests__/watchdog.test.ts +368 -0
  91. package/src/cli/attach.ts +22 -1
  92. package/src/cli/build.ts +12 -4
  93. package/src/cli/context.ts +0 -1
  94. package/src/cli/deploy.ts +7 -5
  95. package/src/cli/index.ts +8 -1
  96. package/src/cli/init.ts +7 -19
  97. package/src/cli/list.ts +6 -10
  98. package/src/cli/local.ts +21 -12
  99. package/src/cli/migrate.ts +6 -4
  100. package/src/cli/nudge.ts +29 -14
  101. package/src/cli/restart.ts +1 -1
  102. package/src/cli/slack.ts +16 -15
  103. package/src/cli/start.ts +14 -0
  104. package/src/cli/stop.ts +14 -5
  105. package/src/cli/test.ts +5 -3
  106. package/src/cli/token.ts +4 -4
  107. package/src/config/loader.ts +29 -2
  108. package/src/config/schema.ts +14 -0
  109. package/src/core/daemon.ts +540 -17
  110. package/src/core/injector.ts +27 -4
  111. package/src/core/registry.ts +14 -1
  112. package/src/core/runner.ts +26 -1
  113. package/src/core/sandbox.ts +550 -0
  114. package/src/core/tmux.ts +35 -2
  115. package/src/core/watchdog.ts +238 -0
  116. package/src/core/websocket.ts +2 -2
  117. package/src/index.ts +6 -5
@@ -12,6 +12,14 @@ export interface DaemonOptions {
12
12
  serve?: boolean;
13
13
  /** Port for opencode serve (default: 3001) */
14
14
  servePort?: number;
15
+ /** Run agent in Docker sandbox container */
16
+ sandbox?: boolean;
17
+ /** Docker image for sandbox (default: agentmesh/agent-sandbox:latest) */
18
+ sandboxImage?: string;
19
+ /** CPU limit for sandbox (default: 1) */
20
+ sandboxCpu?: string;
21
+ /** Memory limit for sandbox (default: 2g) */
22
+ sandboxMemory?: string;
15
23
  }
16
24
  export declare class AgentDaemon {
17
25
  private agentName;
@@ -29,14 +37,51 @@ export declare class AgentDaemon {
29
37
  private serveMode;
30
38
  private servePort;
31
39
  private serveProcess;
40
+ private sandboxMode;
41
+ private sandboxImage;
42
+ private sandboxCpu;
43
+ private sandboxMemory;
44
+ private sandbox;
45
+ private healthCheckInterval;
46
+ private serverContext;
47
+ private restartCount;
48
+ private lastStableTime;
49
+ private stuckSince;
50
+ private nudgeSentAt;
32
51
  constructor(options: DaemonOptions);
33
52
  start(): Promise<void>;
53
+ /**
54
+ * Starts periodic health monitoring for the tmux session
55
+ * Includes auto-restart logic and progress watchdog
56
+ */
57
+ private startHealthMonitor;
58
+ /**
59
+ * Handles session death - logs crash and attempts auto-restart
60
+ */
61
+ private handleSessionDeath;
62
+ /**
63
+ * Handles stuck agent - sends nudge first, then restarts if still stuck
64
+ */
65
+ private handleStuckAgent;
66
+ /**
67
+ * Restarts the agent session (sandbox or non-sandbox)
68
+ */
69
+ private restartSession;
34
70
  stop(): Promise<void>;
35
71
  /**
36
72
  * Starts opencode serve mode (for Integration Service)
37
73
  * Replaces tmux with a direct HTTP server
38
74
  */
39
75
  private startServeMode;
76
+ /**
77
+ * Starts agent in Docker sandbox mode
78
+ * Provides filesystem isolation with only workspace mounted
79
+ *
80
+ * Strategy: Start Docker container with tail -f /dev/null, then create
81
+ * a tmux session on the HOST that runs `docker exec -it <container> opencode`.
82
+ * This way tmux provides the TTY that docker exec needs.
83
+ */
84
+ private startSandboxMode;
40
85
  /**
41
86
  * Saves the current agent context to disk
42
87
  */
@@ -51,4 +96,9 @@ export declare class AgentDaemon {
51
96
  * Returns the absolute path to the workspace
52
97
  */
53
98
  private setupWorkspace;
99
+ /**
100
+ * Ensures the sandbox OpenCode config exists
101
+ * Creates ~/.agentmesh/opencode-sandbox.json with permissive permissions and model
102
+ */
103
+ private ensureSandboxOpencodeConfig;
54
104
  }
@@ -2,14 +2,29 @@ import { execSync, spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { addAgentToState, getAgentState, loadConfig, updateAgentInState, } from "../config/loader.js";
5
+ import { addAgentToState, getAgentState, loadConfig, resetAgentRestartCount, updateAgentInState, } from "../config/loader.js";
6
6
  import { loadContext, loadOrCreateContext, saveContext } from "../context/index.js";
7
7
  import { Heartbeat } from "./heartbeat.js";
8
8
  import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } from "./injector.js";
9
9
  import { checkInbox, fetchAssignments, registerAgent } from "./registry.js";
10
- import { buildRunnerConfig, getRunnerDisplayName, } from "./runner.js";
11
- import { captureSessionContext, createSession, destroySession, getSessionName, sessionExists, updateSessionEnvironment, } from "./tmux.js";
10
+ import { buildRunnerConfig, getRunnerDisplayName } from "./runner.js";
11
+ import { DockerSandbox } from "./sandbox.js";
12
+ import { captureSessionContext, captureSessionOutput, createSession, destroySession, getSessionName, isSessionHealthy, sessionExists, updateSessionEnvironment, } from "./tmux.js";
13
+ import { checkAgentProgress, cleanupOrphanContainers, isProcessRunning, sendNudge, } from "./watchdog.js";
12
14
  import { AgentWebSocket } from "./websocket.js";
15
+ // Maximum number of auto-restart attempts
16
+ const MAX_RESTART_ATTEMPTS = 3;
17
+ // Time after which restart count resets (30 minutes of stable operation)
18
+ const RESTART_COUNT_RESET_MS = 30 * 60 * 1000;
19
+ // Time to wait after nudging before restarting (2 minutes)
20
+ const NUDGE_WAIT_MS = 2 * 60 * 1000;
21
+ // Path to the sandbox OpenCode config (permissive permissions)
22
+ const SANDBOX_OPENCODE_CONFIG_PATH = path.join(os.homedir(), ".agentmesh", "opencode-sandbox.json");
23
+ // Sandbox OpenCode config content - allow everything since container is sandboxed
24
+ const SANDBOX_OPENCODE_CONFIG = {
25
+ $schema: "https://opencode.ai/config.json",
26
+ permission: "allow",
27
+ };
13
28
  export class AgentDaemon {
14
29
  agentName;
15
30
  config;
@@ -26,11 +41,28 @@ export class AgentDaemon {
26
41
  serveMode;
27
42
  servePort;
28
43
  serveProcess = null;
44
+ sandboxMode;
45
+ sandboxImage;
46
+ sandboxCpu;
47
+ sandboxMemory;
48
+ sandbox = null;
49
+ healthCheckInterval = null;
50
+ serverContext;
51
+ // Auto-restart tracking
52
+ restartCount = 0;
53
+ lastStableTime = null;
54
+ stuckSince = null;
55
+ nudgeSentAt = null;
29
56
  constructor(options) {
30
57
  const config = loadConfig();
31
58
  if (!config) {
32
59
  throw new Error("No config found. Run 'agentmesh init' first.");
33
60
  }
61
+ // Ensure config has required fields with defaults
62
+ if (!config.agents)
63
+ config.agents = [];
64
+ if (!config.defaults)
65
+ config.defaults = { command: "opencode", model: "claude-sonnet-4" };
34
66
  this.config = config;
35
67
  this.agentName = options.name;
36
68
  this.shouldRestoreContext = options.restoreContext !== false;
@@ -55,6 +87,10 @@ export class AgentDaemon {
55
87
  this.agentConfig = agentConfig;
56
88
  this.serveMode = options.serve === true;
57
89
  this.servePort = options.servePort || 3001;
90
+ this.sandboxMode = options.sandbox === true;
91
+ this.sandboxImage = options.sandboxImage || "agentmesh/agent-sandbox:latest";
92
+ this.sandboxCpu = options.sandboxCpu || "1";
93
+ this.sandboxMemory = options.sandboxMemory || "2g";
58
94
  // Build runner configuration with model resolution
59
95
  this.runnerConfig = buildRunnerConfig({
60
96
  cliModel: options.model,
@@ -72,9 +108,28 @@ export class AgentDaemon {
72
108
  return;
73
109
  }
74
110
  console.log(`Starting agent: ${this.agentName}`);
111
+ // Check for duplicate process
112
+ const existingState = getAgentState(this.agentName);
113
+ if (existingState && existingState.pid > 0) {
114
+ if (isProcessRunning(existingState.pid)) {
115
+ throw new Error(`Agent "${this.agentName}" is already running (PID: ${existingState.pid}). ` +
116
+ `Use 'agentmesh stop ${this.agentName}' first.`);
117
+ }
118
+ // Process not running, clean up stale state
119
+ console.log(`Cleaning up stale state for PID ${existingState.pid}`);
120
+ }
121
+ // Clean up orphan containers in sandbox mode
122
+ if (this.sandboxMode) {
123
+ const cleaned = cleanupOrphanContainers(this.agentName);
124
+ if (cleaned > 0) {
125
+ console.log(`Cleaned up ${cleaned} orphan container(s)`);
126
+ }
127
+ }
128
+ // Reset restart count on manual start
129
+ this.restartCount = 0;
130
+ this.lastStableTime = new Date();
75
131
  // Register with hub first (needed for assignment check)
76
132
  console.log("Registering with AgentMesh hub...");
77
- const existingState = getAgentState(this.agentName);
78
133
  console.log(`Existing state: ${existingState ? `agentId=${existingState.agentId}` : "none"}`);
79
134
  const registration = await registerAgent({
80
135
  url: this.config.hubUrl,
@@ -83,14 +138,27 @@ export class AgentDaemon {
83
138
  agentId: existingState?.agentId || this.agentConfig.agentId,
84
139
  agentName: this.agentName,
85
140
  model: this.agentConfig.model || this.config.defaults.model,
141
+ restoreContext: this.shouldRestoreContext,
86
142
  });
87
143
  this.agentId = registration.agentId;
88
144
  this.token = registration.token;
89
- console.log(`Registered as: ${this.agentId}`);
145
+ if (registration.status === "re-registered") {
146
+ console.log(`Re-registered as: ${this.agentId}`);
147
+ if (registration.context && Object.keys(registration.context).length > 0) {
148
+ this.serverContext = registration.context;
149
+ console.log(`Server context restored: ${Object.keys(registration.context).join(", ")}`);
150
+ }
151
+ }
152
+ else {
153
+ console.log(`Registered as: ${this.agentId}`);
154
+ }
90
155
  // Check assignments and auto-setup workdir if needed (before creating tmux session)
91
156
  await this.checkAssignments();
92
- // Serve mode: start opencode serve instead of tmux
93
- if (this.serveMode) {
157
+ // Choose runtime mode: sandbox > serve > tmux
158
+ if (this.sandboxMode) {
159
+ await this.startSandboxMode();
160
+ }
161
+ else if (this.serveMode) {
94
162
  await this.startServeMode();
95
163
  }
96
164
  else {
@@ -131,6 +199,7 @@ export class AgentDaemon {
131
199
  assignedProject: this.assignedProject,
132
200
  runtimeModel: this.runnerConfig.model,
133
201
  runnerType: this.runnerConfig.type,
202
+ sandboxContainer: this.sandbox?.getContainerName(),
134
203
  });
135
204
  // Start heartbeat with auto-refresh
136
205
  console.log("Starting heartbeat...");
@@ -211,11 +280,11 @@ export class AgentDaemon {
211
280
  },
212
281
  });
213
282
  this.ws.connect();
214
- // Check inbox and auto-nudge
283
+ // Check inbox and auto-nudge with full handoff details
215
284
  console.log("Checking inbox...");
216
285
  try {
217
286
  const inboxItems = await checkInbox(this.config.hubUrl, this.config.workspace, this.token);
218
- injectStartupMessage(this.agentName, inboxItems.length);
287
+ injectStartupMessage(this.agentName, inboxItems.length, inboxItems);
219
288
  }
220
289
  catch (error) {
221
290
  console.error("Failed to check inbox:", error);
@@ -233,6 +302,8 @@ export class AgentDaemon {
233
302
  }
234
303
  }
235
304
  this.isRunning = true;
305
+ // Start session health monitoring (every 60 seconds)
306
+ this.startHealthMonitor();
236
307
  console.log(`
237
308
  Agent "${this.agentName}" is running.
238
309
 
@@ -249,9 +320,227 @@ Nudge agent:
249
320
  process.on("SIGINT", () => this.stop());
250
321
  process.on("SIGTERM", () => this.stop());
251
322
  }
323
+ /**
324
+ * Starts periodic health monitoring for the tmux session
325
+ * Includes auto-restart logic and progress watchdog
326
+ */
327
+ startHealthMonitor() {
328
+ // Skip health monitoring for serve mode (no tmux session)
329
+ if (this.serveMode)
330
+ return;
331
+ const logDir = path.join(os.homedir(), ".agentmesh", "logs");
332
+ if (!fs.existsSync(logDir)) {
333
+ fs.mkdirSync(logDir, { recursive: true });
334
+ }
335
+ this.healthCheckInterval = setInterval(async () => {
336
+ if (!this.isRunning)
337
+ return;
338
+ // Reset restart count after stable operation
339
+ if (this.lastStableTime && this.restartCount > 0) {
340
+ const stableTime = Date.now() - this.lastStableTime.getTime();
341
+ if (stableTime > RESTART_COUNT_RESET_MS) {
342
+ console.log(`[HEALTH] Agent stable for 30+ minutes, resetting restart count`);
343
+ this.restartCount = 0;
344
+ resetAgentRestartCount(this.agentName);
345
+ }
346
+ }
347
+ const health = isSessionHealthy(this.agentName);
348
+ if (!health.healthy) {
349
+ // Session died - attempt restart
350
+ await this.handleSessionDeath(health.reason || "unknown", logDir);
351
+ return;
352
+ }
353
+ // Session is alive - check progress watchdog
354
+ const containerName = this.sandboxMode ? this.sandbox?.getContainerName() : undefined;
355
+ const progress = checkAgentProgress(this.agentName, containerName);
356
+ if (progress.status === "permission_blocked" || progress.status === "stuck") {
357
+ await this.handleStuckAgent(progress);
358
+ }
359
+ else if (progress.status === "active") {
360
+ // Agent is working - reset stuck tracking
361
+ if (this.stuckSince) {
362
+ console.log(`[HEALTH] Agent resumed activity`);
363
+ this.stuckSince = null;
364
+ this.nudgeSentAt = null;
365
+ updateAgentInState(this.agentName, { stuckSince: undefined, status: "running" });
366
+ }
367
+ this.lastStableTime = new Date();
368
+ }
369
+ }, 60000); // Check every 60 seconds
370
+ }
371
+ /**
372
+ * Handles session death - logs crash and attempts auto-restart
373
+ */
374
+ async handleSessionDeath(reason, logDir) {
375
+ const timestamp = new Date().toISOString();
376
+ const logFile = path.join(logDir, `crash-${this.agentName}.log`);
377
+ // Capture last session output before it's gone
378
+ let lastOutput = "";
379
+ try {
380
+ lastOutput = captureSessionOutput(this.agentName, 200) || "Unable to capture output";
381
+ }
382
+ catch {
383
+ lastOutput = "Failed to capture session output";
384
+ }
385
+ const crashLog = `
386
+ ================================================================================
387
+ AGENT CRASH DETECTED
388
+ ================================================================================
389
+ Timestamp: ${timestamp}
390
+ Agent: ${this.agentName}
391
+ Agent ID: ${this.agentId}
392
+ Reason: ${reason}
393
+ Restart Count: ${this.restartCount}/${MAX_RESTART_ATTEMPTS}
394
+ Sandbox: ${this.sandboxMode ? this.sandbox?.getContainerName() : "none"}
395
+ Workdir: ${this.agentConfig.workdir}
396
+ Model: ${this.runnerConfig.model}
397
+
398
+ --- Last Session Output ---
399
+ ${lastOutput}
400
+ ================================================================================
401
+
402
+ `;
403
+ fs.appendFileSync(logFile, crashLog);
404
+ // Check if we can restart
405
+ if (this.restartCount < MAX_RESTART_ATTEMPTS) {
406
+ this.restartCount++;
407
+ console.error(`[CRASH] Session died: ${reason}. Attempting restart (${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
408
+ updateAgentInState(this.agentName, {
409
+ restartCount: this.restartCount,
410
+ lastRestartAt: timestamp,
411
+ status: "running",
412
+ });
413
+ try {
414
+ await this.restartSession();
415
+ console.log(`[RESTART] Agent restarted successfully`);
416
+ this.lastStableTime = new Date();
417
+ }
418
+ catch (error) {
419
+ console.error(`[RESTART] Failed to restart: ${error.message}`);
420
+ }
421
+ }
422
+ else {
423
+ // Exceeded restart limit - mark as failed
424
+ console.error(`[FAILED] Agent exceeded restart limit (${MAX_RESTART_ATTEMPTS}). Manual intervention required.`);
425
+ // Terminal bell to alert user
426
+ process.stdout.write("\x07");
427
+ updateAgentInState(this.agentName, {
428
+ status: "failed",
429
+ restartCount: this.restartCount,
430
+ });
431
+ // Stop monitoring
432
+ this.isRunning = false;
433
+ if (this.healthCheckInterval) {
434
+ clearInterval(this.healthCheckInterval);
435
+ this.healthCheckInterval = null;
436
+ }
437
+ }
438
+ }
439
+ /**
440
+ * Handles stuck agent - sends nudge first, then restarts if still stuck
441
+ */
442
+ async handleStuckAgent(progress) {
443
+ const now = new Date();
444
+ if (!this.stuckSince) {
445
+ // First detection of stuck state
446
+ this.stuckSince = now;
447
+ console.log(`[HEALTH] Agent appears stuck: ${progress.details || progress.blockedOn || "no activity"}`);
448
+ updateAgentInState(this.agentName, {
449
+ stuckSince: now.toISOString(),
450
+ status: "stuck",
451
+ });
452
+ }
453
+ // If we haven't sent a nudge yet, send one
454
+ if (!this.nudgeSentAt) {
455
+ console.log(`[HEALTH] Sending nudge to unstick agent...`);
456
+ const nudgeMessage = progress.status === "permission_blocked"
457
+ ? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
458
+ : "Please continue with your current task.";
459
+ const sent = sendNudge(this.agentName, nudgeMessage);
460
+ if (sent) {
461
+ this.nudgeSentAt = now;
462
+ console.log(`[HEALTH] Nudge sent successfully`);
463
+ }
464
+ else {
465
+ console.log(`[HEALTH] Failed to send nudge`);
466
+ }
467
+ return;
468
+ }
469
+ // Check if enough time has passed since nudge
470
+ const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
471
+ if (timeSinceNudge < NUDGE_WAIT_MS) {
472
+ // Still waiting for agent to respond to nudge
473
+ return;
474
+ }
475
+ // Agent still stuck after nudge - trigger restart
476
+ console.log(`[HEALTH] Agent still stuck after nudge, triggering restart...`);
477
+ this.stuckSince = null;
478
+ this.nudgeSentAt = null;
479
+ await this.handleSessionDeath("stuck_after_nudge", path.join(os.homedir(), ".agentmesh", "logs"));
480
+ }
481
+ /**
482
+ * Restarts the agent session (sandbox or non-sandbox)
483
+ */
484
+ async restartSession() {
485
+ // Destroy existing session
486
+ destroySession(this.agentName);
487
+ if (this.sandboxMode && this.sandbox) {
488
+ // Restart sandbox container
489
+ const newContainerId = await this.sandbox.restart();
490
+ console.log(`[RESTART] New container: ${newContainerId.substring(0, 12)}`);
491
+ // Recreate tmux session for sandbox
492
+ const containerName = this.sandbox.getContainerName();
493
+ const sessionName = getSessionName(this.agentName);
494
+ // Build environment args for docker exec
495
+ const envArgs = [];
496
+ const allEnv = {
497
+ ...this.runnerConfig.env,
498
+ AGENT_TOKEN: this.token,
499
+ AGENTMESH_AGENT_ID: this.agentId,
500
+ };
501
+ for (const [key, value] of Object.entries(allEnv)) {
502
+ if (value !== undefined && value !== "") {
503
+ envArgs.push(`-e "${key}=${value}"`);
504
+ }
505
+ }
506
+ const envString = envArgs.join(" ");
507
+ const modelArg = this.runnerConfig.env?.OPENCODE_MODEL
508
+ ? ` --model ${this.runnerConfig.env.OPENCODE_MODEL}`
509
+ : "";
510
+ const dockerExecCommand = `docker exec -it ${envString} ${containerName} opencode${modelArg}`;
511
+ const created = createSession(this.agentName, dockerExecCommand, undefined, undefined);
512
+ if (!created) {
513
+ throw new Error("Failed to create tmux session for restarted sandbox");
514
+ }
515
+ // Update state with new container name
516
+ updateAgentInState(this.agentName, {
517
+ sandboxContainer: containerName,
518
+ });
519
+ }
520
+ else {
521
+ // Non-sandbox restart - just recreate tmux session
522
+ const created = createSession(this.agentName, this.agentConfig.command, this.agentConfig.workdir, this.runnerConfig.env);
523
+ if (!created) {
524
+ throw new Error("Failed to create tmux session");
525
+ }
526
+ // Re-inject environment
527
+ updateSessionEnvironment(this.agentName, {
528
+ AGENT_TOKEN: this.token,
529
+ AGENTMESH_AGENT_ID: this.agentId,
530
+ ...this.runnerConfig.env,
531
+ });
532
+ }
533
+ // Wait for session to be ready
534
+ await new Promise((resolve) => setTimeout(resolve, 2000));
535
+ }
252
536
  async stop() {
253
537
  console.log(`\nStopping agent: ${this.agentName}`);
254
538
  this.isRunning = false;
539
+ // Stop health monitor
540
+ if (this.healthCheckInterval) {
541
+ clearInterval(this.healthCheckInterval);
542
+ this.healthCheckInterval = null;
543
+ }
255
544
  // Save context before stopping
256
545
  if (this.agentId) {
257
546
  console.log("Saving agent context...");
@@ -267,8 +556,17 @@ Nudge agent:
267
556
  this.ws.disconnect();
268
557
  this.ws = null;
269
558
  }
270
- // Stop serve process or destroy tmux session
271
- if (this.serveMode && this.serveProcess) {
559
+ // Stop sandbox, serve process, or destroy tmux session
560
+ if (this.sandboxMode && this.sandbox) {
561
+ console.log("Stopping sandbox...");
562
+ // In sandbox mode, we have both a tmux session (on host) and a Docker container
563
+ // Destroy tmux session first (this stops docker exec)
564
+ destroySession(this.agentName);
565
+ // Then destroy the container
566
+ await this.sandbox.destroy();
567
+ this.sandbox = null;
568
+ }
569
+ else if (this.serveMode && this.serveProcess) {
272
570
  console.log("Stopping opencode serve...");
273
571
  this.serveProcess.kill("SIGTERM");
274
572
  this.serveProcess = null;
@@ -325,6 +623,120 @@ Nudge agent:
325
623
  await new Promise((resolve) => setTimeout(resolve, 2000));
326
624
  console.log(`opencode serve started on http://0.0.0.0:${this.servePort}`);
327
625
  }
626
+ /**
627
+ * Starts agent in Docker sandbox mode
628
+ * Provides filesystem isolation with only workspace mounted
629
+ *
630
+ * Strategy: Start Docker container with tail -f /dev/null, then create
631
+ * a tmux session on the HOST that runs `docker exec -it <container> opencode`.
632
+ * This way tmux provides the TTY that docker exec needs.
633
+ */
634
+ async startSandboxMode() {
635
+ console.log("Starting in Docker sandbox mode...");
636
+ // Check Docker availability
637
+ if (!DockerSandbox.checkDockerAvailable()) {
638
+ throw new Error("Docker is not available. Install Docker or use --sandbox host to run on host.");
639
+ }
640
+ const workdir = this.agentConfig.workdir || process.cwd();
641
+ // Check for existing sandbox container
642
+ const existingContainer = DockerSandbox.findExisting(this.agentName);
643
+ if (existingContainer) {
644
+ console.log(`Found existing sandbox container: ${existingContainer}`);
645
+ console.log(`Stop it with: agentmesh stop ${this.agentName}`);
646
+ throw new Error("Sandbox container already exists");
647
+ }
648
+ // Build additional mounts for credentials and config
649
+ // The entrypoint script copies these from /tmp/ to the correct locations
650
+ const additionalMounts = [];
651
+ // Mount git credentials
652
+ const gitCredentialsPath = path.join(os.homedir(), ".git-credentials");
653
+ if (fs.existsSync(gitCredentialsPath)) {
654
+ additionalMounts.push(`${gitCredentialsPath}:/tmp/.git-credentials-host:ro`);
655
+ }
656
+ // Mount OpenCode auth.json for API provider tokens (Anthropic, OpenAI, etc.)
657
+ const opencodeAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
658
+ if (fs.existsSync(opencodeAuthPath)) {
659
+ additionalMounts.push(`${opencodeAuthPath}:/tmp/.opencode-auth-host:ro`);
660
+ }
661
+ // Mount AgentMesh config for hub URL, API key, workspace
662
+ const agentmeshConfigPath = path.join(os.homedir(), ".agentmesh", "config.json");
663
+ if (fs.existsSync(agentmeshConfigPath)) {
664
+ additionalMounts.push(`${agentmeshConfigPath}:/tmp/.agentmesh-config-host:ro`);
665
+ }
666
+ // Create and mount permissive OpenCode config for sandbox
667
+ // This allows all permissions since the container is already sandboxed
668
+ this.ensureSandboxOpencodeConfig();
669
+ additionalMounts.push(`${SANDBOX_OPENCODE_CONFIG_PATH}:/workspace/opencode.json:ro`);
670
+ // Pass GitHub token as environment variable for git operations
671
+ const gitCredentials = fs.existsSync(gitCredentialsPath)
672
+ ? fs.readFileSync(gitCredentialsPath, "utf-8").trim()
673
+ : "";
674
+ const gitHubToken = gitCredentials.match(/github_pat_[^\s@]+/)?.[0] || "";
675
+ // Build the command to run inside the container
676
+ // The agentmesh CLI inside the container will create tmux + opencode
677
+ const model = this.runnerConfig.env?.OPENCODE_MODEL || this.runnerConfig.model || "claude-sonnet-4";
678
+ const containerCommand = [
679
+ "agentmesh",
680
+ "start",
681
+ "--name",
682
+ this.agentName,
683
+ "--model",
684
+ model,
685
+ "--foreground",
686
+ ];
687
+ // Create sandbox configuration
688
+ this.sandbox = new DockerSandbox({
689
+ agentName: this.agentName,
690
+ image: this.sandboxImage,
691
+ workspacePath: workdir,
692
+ cpuLimit: this.sandboxCpu,
693
+ memoryLimit: this.sandboxMemory,
694
+ env: {
695
+ ...this.runnerConfig.env,
696
+ AGENT_TOKEN: this.token,
697
+ AGENTMESH_AGENT_ID: this.agentId,
698
+ // Git credentials for pushing to GitHub
699
+ ...(gitHubToken && { GH_TOKEN: gitHubToken, GITHUB_TOKEN: gitHubToken }),
700
+ },
701
+ serveMode: this.serveMode,
702
+ servePort: this.servePort,
703
+ networkMode: "bridge",
704
+ additionalMounts: additionalMounts.length > 0 ? additionalMounts : undefined,
705
+ command: this.serveMode ? undefined : containerCommand,
706
+ });
707
+ // Validate mount policy (will throw if denied)
708
+ this.sandbox.validateMountPolicy();
709
+ // Pull image if needed
710
+ await this.sandbox.pullImage();
711
+ // Start container with agentmesh running inside
712
+ // The entrypoint script sets up credentials before agentmesh starts
713
+ await this.sandbox.start();
714
+ const containerName = this.sandbox.getContainerName();
715
+ console.log(`
716
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
717
+ SANDBOX MODE ACTIVE
718
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
719
+
720
+ Container: ${containerName}
721
+ Image: ${this.sandboxImage}
722
+ Workspace: ${workdir} -> /workspace
723
+ CPU: ${this.sandboxCpu} core(s)
724
+ Memory: ${this.sandboxMemory}
725
+ Model: ${model}
726
+
727
+ The agent daemon is running INSIDE the Docker container.
728
+ tmux session and OpenCode are managed inside the container.
729
+
730
+ Attach: agentmesh attach ${this.agentName}
731
+ Nudge: agentmesh nudge ${this.agentName} "message"
732
+ Stop: agentmesh stop ${this.agentName}
733
+ Logs: docker logs ${containerName}
734
+
735
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
736
+ `);
737
+ // No host tmux session needed - the container runs agentmesh which creates its own tmux
738
+ // Heartbeats are sent by the daemon running inside the container
739
+ }
328
740
  /**
329
741
  * Saves the current agent context to disk
330
742
  */
@@ -484,5 +896,27 @@ Option 3: Use --auto-setup to automatically clone the repository:
484
896
  console.log(`✓ Workspace ready: ${workspacePath}\n`);
485
897
  return workspacePath;
486
898
  }
899
+ /**
900
+ * Ensures the sandbox OpenCode config exists
901
+ * Creates ~/.agentmesh/opencode-sandbox.json with permissive permissions and model
902
+ */
903
+ ensureSandboxOpencodeConfig() {
904
+ const configDir = path.dirname(SANDBOX_OPENCODE_CONFIG_PATH);
905
+ if (!fs.existsSync(configDir)) {
906
+ fs.mkdirSync(configDir, { recursive: true });
907
+ }
908
+ // Build config with model if available
909
+ const config = {
910
+ ...SANDBOX_OPENCODE_CONFIG,
911
+ };
912
+ // Include model from runner config
913
+ const model = this.runnerConfig.env?.OPENCODE_MODEL;
914
+ if (model) {
915
+ config.model = model;
916
+ }
917
+ // Always write to ensure model is up to date
918
+ fs.writeFileSync(SANDBOX_OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
919
+ console.log(`Updated sandbox OpenCode config: ${SANDBOX_OPENCODE_CONFIG_PATH}`);
920
+ }
487
921
  }
488
922
  //# sourceMappingURL=daemon.js.map