@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
@@ -2,15 +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";
10
+ import { buildRunnerConfig, getRunnerDisplayName } from "./runner.js";
11
11
  import { DockerSandbox } from "./sandbox.js";
12
- import { captureSessionContext, createSession, destroySession, getSessionName, sessionExists, updateSessionEnvironment, } from "./tmux.js";
12
+ import { captureSessionContext, captureSessionOutput, createSession, destroySession, getSessionName, isSessionHealthy, sessionExists, updateSessionEnvironment, } from "./tmux.js";
13
+ import { checkAgentProgress, cleanupOrphanContainers, isProcessRunning, sendNudge, } from "./watchdog.js";
13
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
+ };
14
28
  export class AgentDaemon {
15
29
  agentName;
16
30
  config;
@@ -27,17 +41,28 @@ export class AgentDaemon {
27
41
  serveMode;
28
42
  servePort;
29
43
  serveProcess = null;
30
- serverContext;
31
44
  sandboxMode;
32
45
  sandboxImage;
33
46
  sandboxCpu;
34
47
  sandboxMemory;
35
48
  sandbox = null;
49
+ healthCheckInterval = null;
50
+ serverContext;
51
+ // Auto-restart tracking
52
+ restartCount = 0;
53
+ lastStableTime = null;
54
+ stuckSince = null;
55
+ nudgeSentAt = null;
36
56
  constructor(options) {
37
57
  const config = loadConfig();
38
58
  if (!config) {
39
59
  throw new Error("No config found. Run 'agentmesh init' first.");
40
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" };
41
66
  this.config = config;
42
67
  this.agentName = options.name;
43
68
  this.shouldRestoreContext = options.restoreContext !== false;
@@ -83,9 +108,28 @@ export class AgentDaemon {
83
108
  return;
84
109
  }
85
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();
86
131
  // Register with hub first (needed for assignment check)
87
132
  console.log("Registering with AgentMesh hub...");
88
- const existingState = getAgentState(this.agentName);
89
133
  console.log(`Existing state: ${existingState ? `agentId=${existingState.agentId}` : "none"}`);
90
134
  const registration = await registerAgent({
91
135
  url: this.config.hubUrl,
@@ -155,6 +199,7 @@ export class AgentDaemon {
155
199
  assignedProject: this.assignedProject,
156
200
  runtimeModel: this.runnerConfig.model,
157
201
  runnerType: this.runnerConfig.type,
202
+ sandboxContainer: this.sandbox?.getContainerName(),
158
203
  });
159
204
  // Start heartbeat with auto-refresh
160
205
  console.log("Starting heartbeat...");
@@ -235,11 +280,11 @@ export class AgentDaemon {
235
280
  },
236
281
  });
237
282
  this.ws.connect();
238
- // Check inbox and auto-nudge
283
+ // Check inbox and auto-nudge with full handoff details
239
284
  console.log("Checking inbox...");
240
285
  try {
241
286
  const inboxItems = await checkInbox(this.config.hubUrl, this.config.workspace, this.token);
242
- injectStartupMessage(this.agentName, inboxItems.length);
287
+ injectStartupMessage(this.agentName, inboxItems.length, inboxItems);
243
288
  }
244
289
  catch (error) {
245
290
  console.error("Failed to check inbox:", error);
@@ -257,6 +302,8 @@ export class AgentDaemon {
257
302
  }
258
303
  }
259
304
  this.isRunning = true;
305
+ // Start session health monitoring (every 60 seconds)
306
+ this.startHealthMonitor();
260
307
  console.log(`
261
308
  Agent "${this.agentName}" is running.
262
309
 
@@ -273,9 +320,228 @@ Nudge agent:
273
320
  process.on("SIGINT", () => this.stop());
274
321
  process.on("SIGTERM", () => this.stop());
275
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
+ // For sandbox mode, pass container name so health check looks inside container
348
+ const containerName = this.sandboxMode ? this.sandbox?.getContainerName() : undefined;
349
+ const health = isSessionHealthy(this.agentName, containerName);
350
+ if (!health.healthy) {
351
+ // Session died - attempt restart
352
+ await this.handleSessionDeath(health.reason || "unknown", logDir);
353
+ return;
354
+ }
355
+ // Session is alive - check progress watchdog
356
+ const progress = checkAgentProgress(this.agentName, containerName);
357
+ if (progress.status === "permission_blocked" || progress.status === "stuck") {
358
+ await this.handleStuckAgent(progress);
359
+ }
360
+ else if (progress.status === "active") {
361
+ // Agent is working - reset stuck tracking
362
+ if (this.stuckSince) {
363
+ console.log(`[HEALTH] Agent resumed activity`);
364
+ this.stuckSince = null;
365
+ this.nudgeSentAt = null;
366
+ updateAgentInState(this.agentName, { stuckSince: undefined, status: "running" });
367
+ }
368
+ this.lastStableTime = new Date();
369
+ }
370
+ }, 60000); // Check every 60 seconds
371
+ }
372
+ /**
373
+ * Handles session death - logs crash and attempts auto-restart
374
+ */
375
+ async handleSessionDeath(reason, logDir) {
376
+ const timestamp = new Date().toISOString();
377
+ const logFile = path.join(logDir, `crash-${this.agentName}.log`);
378
+ // Capture last session output before it's gone
379
+ let lastOutput = "";
380
+ try {
381
+ lastOutput = captureSessionOutput(this.agentName, 200) || "Unable to capture output";
382
+ }
383
+ catch {
384
+ lastOutput = "Failed to capture session output";
385
+ }
386
+ const crashLog = `
387
+ ================================================================================
388
+ AGENT CRASH DETECTED
389
+ ================================================================================
390
+ Timestamp: ${timestamp}
391
+ Agent: ${this.agentName}
392
+ Agent ID: ${this.agentId}
393
+ Reason: ${reason}
394
+ Restart Count: ${this.restartCount}/${MAX_RESTART_ATTEMPTS}
395
+ Sandbox: ${this.sandboxMode ? this.sandbox?.getContainerName() : "none"}
396
+ Workdir: ${this.agentConfig.workdir}
397
+ Model: ${this.runnerConfig.model}
398
+
399
+ --- Last Session Output ---
400
+ ${lastOutput}
401
+ ================================================================================
402
+
403
+ `;
404
+ fs.appendFileSync(logFile, crashLog);
405
+ // Check if we can restart
406
+ if (this.restartCount < MAX_RESTART_ATTEMPTS) {
407
+ this.restartCount++;
408
+ console.error(`[CRASH] Session died: ${reason}. Attempting restart (${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
409
+ updateAgentInState(this.agentName, {
410
+ restartCount: this.restartCount,
411
+ lastRestartAt: timestamp,
412
+ status: "running",
413
+ });
414
+ try {
415
+ await this.restartSession();
416
+ console.log(`[RESTART] Agent restarted successfully`);
417
+ this.lastStableTime = new Date();
418
+ }
419
+ catch (error) {
420
+ console.error(`[RESTART] Failed to restart: ${error.message}`);
421
+ }
422
+ }
423
+ else {
424
+ // Exceeded restart limit - mark as failed
425
+ console.error(`[FAILED] Agent exceeded restart limit (${MAX_RESTART_ATTEMPTS}). Manual intervention required.`);
426
+ // Terminal bell to alert user
427
+ process.stdout.write("\x07");
428
+ updateAgentInState(this.agentName, {
429
+ status: "failed",
430
+ restartCount: this.restartCount,
431
+ });
432
+ // Stop monitoring
433
+ this.isRunning = false;
434
+ if (this.healthCheckInterval) {
435
+ clearInterval(this.healthCheckInterval);
436
+ this.healthCheckInterval = null;
437
+ }
438
+ }
439
+ }
440
+ /**
441
+ * Handles stuck agent - sends nudge first, then restarts if still stuck
442
+ */
443
+ async handleStuckAgent(progress) {
444
+ const now = new Date();
445
+ if (!this.stuckSince) {
446
+ // First detection of stuck state
447
+ this.stuckSince = now;
448
+ console.log(`[HEALTH] Agent appears stuck: ${progress.details || progress.blockedOn || "no activity"}`);
449
+ updateAgentInState(this.agentName, {
450
+ stuckSince: now.toISOString(),
451
+ status: "stuck",
452
+ });
453
+ }
454
+ // If we haven't sent a nudge yet, send one
455
+ if (!this.nudgeSentAt) {
456
+ console.log(`[HEALTH] Sending nudge to unstick agent...`);
457
+ const nudgeMessage = progress.status === "permission_blocked"
458
+ ? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
459
+ : "Please continue with your current task.";
460
+ const sent = sendNudge(this.agentName, nudgeMessage);
461
+ if (sent) {
462
+ this.nudgeSentAt = now;
463
+ console.log(`[HEALTH] Nudge sent successfully`);
464
+ }
465
+ else {
466
+ console.log(`[HEALTH] Failed to send nudge`);
467
+ }
468
+ return;
469
+ }
470
+ // Check if enough time has passed since nudge
471
+ const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
472
+ if (timeSinceNudge < NUDGE_WAIT_MS) {
473
+ // Still waiting for agent to respond to nudge
474
+ return;
475
+ }
476
+ // Agent still stuck after nudge - trigger restart
477
+ console.log(`[HEALTH] Agent still stuck after nudge, triggering restart...`);
478
+ this.stuckSince = null;
479
+ this.nudgeSentAt = null;
480
+ await this.handleSessionDeath("stuck_after_nudge", path.join(os.homedir(), ".agentmesh", "logs"));
481
+ }
482
+ /**
483
+ * Restarts the agent session (sandbox or non-sandbox)
484
+ */
485
+ async restartSession() {
486
+ // Destroy existing session
487
+ destroySession(this.agentName);
488
+ if (this.sandboxMode && this.sandbox) {
489
+ // Restart sandbox container
490
+ const newContainerId = await this.sandbox.restart();
491
+ console.log(`[RESTART] New container: ${newContainerId.substring(0, 12)}`);
492
+ // Recreate tmux session for sandbox
493
+ const containerName = this.sandbox.getContainerName();
494
+ const sessionName = getSessionName(this.agentName);
495
+ // Build environment args for docker exec
496
+ const envArgs = [];
497
+ const allEnv = {
498
+ ...this.runnerConfig.env,
499
+ AGENT_TOKEN: this.token,
500
+ AGENTMESH_AGENT_ID: this.agentId,
501
+ };
502
+ for (const [key, value] of Object.entries(allEnv)) {
503
+ if (value !== undefined && value !== "") {
504
+ envArgs.push(`-e "${key}=${value}"`);
505
+ }
506
+ }
507
+ const envString = envArgs.join(" ");
508
+ const modelArg = this.runnerConfig.env?.OPENCODE_MODEL
509
+ ? ` --model ${this.runnerConfig.env.OPENCODE_MODEL}`
510
+ : "";
511
+ const dockerExecCommand = `docker exec -it ${envString} ${containerName} opencode${modelArg}`;
512
+ const created = createSession(this.agentName, dockerExecCommand, undefined, undefined);
513
+ if (!created) {
514
+ throw new Error("Failed to create tmux session for restarted sandbox");
515
+ }
516
+ // Update state with new container name
517
+ updateAgentInState(this.agentName, {
518
+ sandboxContainer: containerName,
519
+ });
520
+ }
521
+ else {
522
+ // Non-sandbox restart - just recreate tmux session
523
+ const created = createSession(this.agentName, this.agentConfig.command, this.agentConfig.workdir, this.runnerConfig.env);
524
+ if (!created) {
525
+ throw new Error("Failed to create tmux session");
526
+ }
527
+ // Re-inject environment
528
+ updateSessionEnvironment(this.agentName, {
529
+ AGENT_TOKEN: this.token,
530
+ AGENTMESH_AGENT_ID: this.agentId,
531
+ ...this.runnerConfig.env,
532
+ });
533
+ }
534
+ // Wait for session to be ready
535
+ await new Promise((resolve) => setTimeout(resolve, 2000));
536
+ }
276
537
  async stop() {
277
538
  console.log(`\nStopping agent: ${this.agentName}`);
278
539
  this.isRunning = false;
540
+ // Stop health monitor
541
+ if (this.healthCheckInterval) {
542
+ clearInterval(this.healthCheckInterval);
543
+ this.healthCheckInterval = null;
544
+ }
279
545
  // Save context before stopping
280
546
  if (this.agentId) {
281
547
  console.log("Saving agent context...");
@@ -293,7 +559,11 @@ Nudge agent:
293
559
  }
294
560
  // Stop sandbox, serve process, or destroy tmux session
295
561
  if (this.sandboxMode && this.sandbox) {
296
- console.log("Stopping sandbox container...");
562
+ console.log("Stopping sandbox...");
563
+ // In sandbox mode, we have both a tmux session (on host) and a Docker container
564
+ // Destroy tmux session first (this stops docker exec)
565
+ destroySession(this.agentName);
566
+ // Then destroy the container
297
567
  await this.sandbox.destroy();
298
568
  this.sandbox = null;
299
569
  }
@@ -322,12 +592,34 @@ Nudge agent:
322
592
  async startServeMode() {
323
593
  console.log(`Starting opencode serve mode on port ${this.servePort}...`);
324
594
  const workdir = this.agentConfig.workdir || process.cwd();
595
+ // Isolate OpenCode's SQLite database per agent to prevent WAL corruption.
596
+ // See docs/RCA-OPENCODE-SQLITE-CORRUPTION.md for details.
597
+ const agentDataDir = path.join(os.homedir(), ".agentmesh", "opencode-data", this.agentName);
598
+ const agentOpencodeDir = path.join(agentDataDir, "opencode");
599
+ if (!fs.existsSync(agentOpencodeDir)) {
600
+ fs.mkdirSync(agentOpencodeDir, { recursive: true });
601
+ }
602
+ // Copy auth.json from default OpenCode data dir so agents inherit API keys.
603
+ // Strips xAI provider to prevent OpenCode from defaulting to non-Anthropic models.
604
+ const agentAuthPath = path.join(agentOpencodeDir, "auth.json");
605
+ const sourceAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
606
+ if (!fs.existsSync(agentAuthPath) && fs.existsSync(sourceAuthPath)) {
607
+ try {
608
+ const auth = JSON.parse(fs.readFileSync(sourceAuthPath, "utf-8"));
609
+ delete auth.xai;
610
+ fs.writeFileSync(agentAuthPath, JSON.stringify(auth, null, 2));
611
+ }
612
+ catch {
613
+ // Non-fatal — agent will just need manual auth
614
+ }
615
+ }
325
616
  // Build environment for opencode serve
326
617
  const env = {
327
618
  ...process.env,
328
619
  ...this.runnerConfig.env,
329
620
  AGENT_TOKEN: this.token,
330
621
  AGENTMESH_AGENT_ID: this.agentId,
622
+ XDG_DATA_HOME: agentDataDir,
331
623
  };
332
624
  // Spawn opencode serve as a child process
333
625
  this.serveProcess = spawn("opencode", ["serve", "--port", String(this.servePort), "--hostname", "0.0.0.0"], {
@@ -357,6 +649,10 @@ Nudge agent:
357
649
  /**
358
650
  * Starts agent in Docker sandbox mode
359
651
  * Provides filesystem isolation with only workspace mounted
652
+ *
653
+ * Strategy: Start Docker container with tail -f /dev/null, then create
654
+ * a tmux session on the HOST that runs `docker exec -it <container> opencode`.
655
+ * This way tmux provides the TTY that docker exec needs.
360
656
  */
361
657
  async startSandboxMode() {
362
658
  console.log("Starting in Docker sandbox mode...");
@@ -369,10 +665,54 @@ Nudge agent:
369
665
  const existingContainer = DockerSandbox.findExisting(this.agentName);
370
666
  if (existingContainer) {
371
667
  console.log(`Found existing sandbox container: ${existingContainer}`);
372
- console.log("Stop it with: agentmesh stop " + this.agentName);
668
+ console.log(`Stop it with: agentmesh stop ${this.agentName}`);
373
669
  throw new Error("Sandbox container already exists");
374
670
  }
671
+ // Build additional mounts for credentials and config
672
+ // The entrypoint script copies these from /tmp/ to the correct locations
673
+ const additionalMounts = [];
674
+ // Mount git credentials
675
+ const gitCredentialsPath = path.join(os.homedir(), ".git-credentials");
676
+ if (fs.existsSync(gitCredentialsPath)) {
677
+ additionalMounts.push(`${gitCredentialsPath}:/tmp/.git-credentials-host:ro`);
678
+ }
679
+ // Mount OpenCode auth.json for API provider tokens (Anthropic, OpenAI, etc.)
680
+ const opencodeAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
681
+ if (fs.existsSync(opencodeAuthPath)) {
682
+ additionalMounts.push(`${opencodeAuthPath}:/tmp/.opencode-auth-host:ro`);
683
+ }
684
+ // Mount AgentMesh config for hub URL, API key, workspace
685
+ const agentmeshConfigPath = path.join(os.homedir(), ".agentmesh", "config.json");
686
+ if (fs.existsSync(agentmeshConfigPath)) {
687
+ additionalMounts.push(`${agentmeshConfigPath}:/tmp/.agentmesh-config-host:ro`);
688
+ }
689
+ // Create and mount permissive OpenCode config for sandbox
690
+ // This allows all permissions since the container is already sandboxed
691
+ this.ensureSandboxOpencodeConfig();
692
+ additionalMounts.push(`${SANDBOX_OPENCODE_CONFIG_PATH}:/workspace/opencode.json:ro`);
693
+ // Pass GitHub token as environment variable for git operations
694
+ const gitCredentials = fs.existsSync(gitCredentialsPath)
695
+ ? fs.readFileSync(gitCredentialsPath, "utf-8").trim()
696
+ : "";
697
+ const gitHubToken = gitCredentials.match(/github_pat_[^\s@]+/)?.[0] || "";
698
+ // Build the command to run inside the container
699
+ // The agentmesh CLI inside the container will create tmux + opencode
700
+ const model = this.runnerConfig.env?.OPENCODE_MODEL || this.runnerConfig.model || "claude-sonnet-4";
701
+ const containerCommand = [
702
+ "agentmesh",
703
+ "start",
704
+ "--name",
705
+ this.agentName,
706
+ "--model",
707
+ model,
708
+ "--foreground",
709
+ ];
375
710
  // Create sandbox configuration
711
+ // Isolate OpenCode's SQLite database per agent to prevent WAL corruption.
712
+ const hostDataDir = path.join(os.homedir(), ".agentmesh", "opencode-data", this.agentName);
713
+ if (!fs.existsSync(hostDataDir)) {
714
+ fs.mkdirSync(hostDataDir, { recursive: true });
715
+ }
376
716
  this.sandbox = new DockerSandbox({
377
717
  agentName: this.agentName,
378
718
  image: this.sandboxImage,
@@ -383,38 +723,52 @@ Nudge agent:
383
723
  ...this.runnerConfig.env,
384
724
  AGENT_TOKEN: this.token,
385
725
  AGENTMESH_AGENT_ID: this.agentId,
726
+ AGENT_NAME: this.agentName,
727
+ // XDG_DATA_HOME set by entrypoint based on AGENT_NAME
728
+ // Git credentials for pushing to GitHub
729
+ ...(gitHubToken && { GH_TOKEN: gitHubToken, GITHUB_TOKEN: gitHubToken }),
386
730
  },
387
731
  serveMode: this.serveMode,
388
732
  servePort: this.servePort,
389
733
  networkMode: "bridge",
734
+ additionalMounts: [
735
+ ...additionalMounts,
736
+ `${hostDataDir}:/home/node/.agentmesh/opencode-data/${this.agentName}:rw`,
737
+ ],
738
+ command: this.serveMode ? undefined : containerCommand,
390
739
  });
391
740
  // Validate mount policy (will throw if denied)
392
741
  this.sandbox.validateMountPolicy();
393
742
  // Pull image if needed
394
743
  await this.sandbox.pullImage();
395
- // Start container
744
+ // Start container with agentmesh running inside
745
+ // The entrypoint script sets up credentials before agentmesh starts
396
746
  await this.sandbox.start();
747
+ const containerName = this.sandbox.getContainerName();
397
748
  console.log(`
398
749
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399
- 🐳 SANDBOX MODE ACTIVE
750
+ SANDBOX MODE ACTIVE
400
751
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
401
752
 
402
- Container: ${this.sandbox.getContainerName()}
753
+ Container: ${containerName}
403
754
  Image: ${this.sandboxImage}
404
755
  Workspace: ${workdir} -> /workspace
405
756
  CPU: ${this.sandboxCpu} core(s)
406
757
  Memory: ${this.sandboxMemory}
758
+ Model: ${model}
407
759
 
408
- The agent is running in an isolated Docker container.
409
- Only the workspace directory is accessible.
760
+ The agent daemon is running INSIDE the Docker container.
761
+ tmux session and OpenCode are managed inside the container.
762
+
763
+ Attach: agentmesh attach ${this.agentName}
764
+ Nudge: agentmesh nudge ${this.agentName} "message"
765
+ Stop: agentmesh stop ${this.agentName}
766
+ Logs: docker logs ${containerName}
410
767
 
411
768
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
412
769
  `);
413
- // Start opencode in the container
414
- if (!this.serveMode) {
415
- console.log("Starting opencode in sandbox container...");
416
- await this.sandbox.spawnOpencode();
417
- }
770
+ // No host tmux session needed - the container runs agentmesh which creates its own tmux
771
+ // Heartbeats are sent by the daemon running inside the container
418
772
  }
419
773
  /**
420
774
  * Saves the current agent context to disk
@@ -575,5 +929,27 @@ Option 3: Use --auto-setup to automatically clone the repository:
575
929
  console.log(`✓ Workspace ready: ${workspacePath}\n`);
576
930
  return workspacePath;
577
931
  }
932
+ /**
933
+ * Ensures the sandbox OpenCode config exists
934
+ * Creates ~/.agentmesh/opencode-sandbox.json with permissive permissions and model
935
+ */
936
+ ensureSandboxOpencodeConfig() {
937
+ const configDir = path.dirname(SANDBOX_OPENCODE_CONFIG_PATH);
938
+ if (!fs.existsSync(configDir)) {
939
+ fs.mkdirSync(configDir, { recursive: true });
940
+ }
941
+ // Build config with model if available
942
+ const config = {
943
+ ...SANDBOX_OPENCODE_CONFIG,
944
+ };
945
+ // Include model from runner config
946
+ const model = this.runnerConfig.env?.OPENCODE_MODEL;
947
+ if (model) {
948
+ config.model = model;
949
+ }
950
+ // Always write to ensure model is up to date
951
+ fs.writeFileSync(SANDBOX_OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
952
+ console.log(`Updated sandbox OpenCode config: ${SANDBOX_OPENCODE_CONFIG_PATH}`);
953
+ }
578
954
  }
579
955
  //# sourceMappingURL=daemon.js.map