@agentmeshhq/agent 0.1.13 → 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 (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 +362 -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 +23 -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 +8 -0
  51. package/dist/core/tmux.js +28 -1
  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 +439 -24
  83. package/src/core/injector.ts +27 -4
  84. package/src/core/runner.ts +26 -1
  85. package/src/core/sandbox.ts +47 -2
  86. package/src/core/tmux.ts +35 -2
  87. package/src/core/watchdog.ts +238 -0
  88. package/src/core/websocket.ts +2 -2
  89. package/src/index.ts +6 -5
@@ -6,31 +6,50 @@ import {
6
6
  addAgentToState,
7
7
  getAgentState,
8
8
  loadConfig,
9
- removeAgentFromState,
9
+ resetAgentRestartCount,
10
10
  updateAgentInState,
11
11
  } from "../config/loader.js";
12
- import type { AgentConfig, Config } from "../config/schema.js";
12
+ import type { AgentConfig, AgentStatus, Config } from "../config/schema.js";
13
13
  import { loadContext, loadOrCreateContext, saveContext } from "../context/index.js";
14
14
  import { Heartbeat } from "./heartbeat.js";
15
15
  import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } from "./injector.js";
16
16
  import { checkInbox, fetchAssignments, registerAgent, type ServerContext } from "./registry.js";
17
- import {
18
- buildRunnerConfig,
19
- detectRunner,
20
- getRunnerDisplayName,
21
- type RunnerConfig,
22
- } from "./runner.js";
17
+ import { buildRunnerConfig, getRunnerDisplayName, type RunnerConfig } from "./runner.js";
23
18
  import { DockerSandbox } from "./sandbox.js";
24
19
  import {
25
20
  captureSessionContext,
21
+ captureSessionOutput,
26
22
  createSession,
27
23
  destroySession,
28
24
  getSessionName,
25
+ isSessionHealthy,
29
26
  sessionExists,
30
27
  updateSessionEnvironment,
31
28
  } from "./tmux.js";
29
+ import {
30
+ checkAgentProgress,
31
+ cleanupOrphanContainers,
32
+ isProcessRunning,
33
+ sendNudge,
34
+ } from "./watchdog.js";
32
35
  import { AgentWebSocket } from "./websocket.js";
33
36
 
37
+ // Maximum number of auto-restart attempts
38
+ const MAX_RESTART_ATTEMPTS = 3;
39
+ // Time after which restart count resets (30 minutes of stable operation)
40
+ const RESTART_COUNT_RESET_MS = 30 * 60 * 1000;
41
+ // Time to wait after nudging before restarting (2 minutes)
42
+ const NUDGE_WAIT_MS = 2 * 60 * 1000;
43
+
44
+ // Path to the sandbox OpenCode config (permissive permissions)
45
+ const SANDBOX_OPENCODE_CONFIG_PATH = path.join(os.homedir(), ".agentmesh", "opencode-sandbox.json");
46
+
47
+ // Sandbox OpenCode config content - allow everything since container is sandboxed
48
+ const SANDBOX_OPENCODE_CONFIG = {
49
+ $schema: "https://opencode.ai/config.json",
50
+ permission: "allow",
51
+ };
52
+
34
53
  export interface DaemonOptions {
35
54
  name: string;
36
55
  command?: string;
@@ -71,12 +90,18 @@ export class AgentDaemon {
71
90
  private serveMode: boolean;
72
91
  private servePort: number;
73
92
  private serveProcess: ChildProcess | null = null;
74
- private serverContext: ServerContext | undefined;
75
93
  private sandboxMode: boolean;
76
94
  private sandboxImage: string;
77
95
  private sandboxCpu: string;
78
96
  private sandboxMemory: string;
79
97
  private sandbox: DockerSandbox | null = null;
98
+ private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
99
+ private serverContext: ServerContext | undefined;
100
+ // Auto-restart tracking
101
+ private restartCount = 0;
102
+ private lastStableTime: Date | null = null;
103
+ private stuckSince: Date | null = null;
104
+ private nudgeSentAt: Date | null = null;
80
105
 
81
106
  constructor(options: DaemonOptions) {
82
107
  const config = loadConfig();
@@ -84,6 +109,10 @@ export class AgentDaemon {
84
109
  throw new Error("No config found. Run 'agentmesh init' first.");
85
110
  }
86
111
 
112
+ // Ensure config has required fields with defaults
113
+ if (!config.agents) config.agents = [];
114
+ if (!config.defaults) config.defaults = { command: "opencode", model: "claude-sonnet-4" };
115
+
87
116
  this.config = config;
88
117
  this.agentName = options.name;
89
118
  this.shouldRestoreContext = options.restoreContext !== false;
@@ -135,9 +164,33 @@ export class AgentDaemon {
135
164
 
136
165
  console.log(`Starting agent: ${this.agentName}`);
137
166
 
167
+ // Check for duplicate process
168
+ const existingState = getAgentState(this.agentName);
169
+ if (existingState && existingState.pid > 0) {
170
+ if (isProcessRunning(existingState.pid)) {
171
+ throw new Error(
172
+ `Agent "${this.agentName}" is already running (PID: ${existingState.pid}). ` +
173
+ `Use 'agentmesh stop ${this.agentName}' first.`,
174
+ );
175
+ }
176
+ // Process not running, clean up stale state
177
+ console.log(`Cleaning up stale state for PID ${existingState.pid}`);
178
+ }
179
+
180
+ // Clean up orphan containers in sandbox mode
181
+ if (this.sandboxMode) {
182
+ const cleaned = cleanupOrphanContainers(this.agentName);
183
+ if (cleaned > 0) {
184
+ console.log(`Cleaned up ${cleaned} orphan container(s)`);
185
+ }
186
+ }
187
+
188
+ // Reset restart count on manual start
189
+ this.restartCount = 0;
190
+ this.lastStableTime = new Date();
191
+
138
192
  // Register with hub first (needed for assignment check)
139
193
  console.log("Registering with AgentMesh hub...");
140
- const existingState = getAgentState(this.agentName);
141
194
  console.log(`Existing state: ${existingState ? `agentId=${existingState.agentId}` : "none"}`);
142
195
 
143
196
  const registration = await registerAgent({
@@ -218,6 +271,7 @@ export class AgentDaemon {
218
271
  assignedProject: this.assignedProject,
219
272
  runtimeModel: this.runnerConfig.model,
220
273
  runnerType: this.runnerConfig.type,
274
+ sandboxContainer: this.sandbox?.getContainerName(),
221
275
  });
222
276
 
223
277
  // Start heartbeat with auto-refresh
@@ -302,11 +356,11 @@ export class AgentDaemon {
302
356
  });
303
357
  this.ws.connect();
304
358
 
305
- // Check inbox and auto-nudge
359
+ // Check inbox and auto-nudge with full handoff details
306
360
  console.log("Checking inbox...");
307
361
  try {
308
362
  const inboxItems = await checkInbox(this.config.hubUrl, this.config.workspace, this.token);
309
- injectStartupMessage(this.agentName, inboxItems.length);
363
+ injectStartupMessage(this.agentName, inboxItems.length, inboxItems);
310
364
  } catch (error) {
311
365
  console.error("Failed to check inbox:", error);
312
366
  injectStartupMessage(this.agentName, 0);
@@ -326,6 +380,9 @@ export class AgentDaemon {
326
380
 
327
381
  this.isRunning = true;
328
382
 
383
+ // Start session health monitoring (every 60 seconds)
384
+ this.startHealthMonitor();
385
+
329
386
  console.log(`
330
387
  Agent "${this.agentName}" is running.
331
388
 
@@ -344,11 +401,277 @@ Nudge agent:
344
401
  process.on("SIGTERM", () => this.stop());
345
402
  }
346
403
 
404
+ /**
405
+ * Starts periodic health monitoring for the tmux session
406
+ * Includes auto-restart logic and progress watchdog
407
+ */
408
+ private startHealthMonitor(): void {
409
+ // Skip health monitoring for serve mode (no tmux session)
410
+ if (this.serveMode) return;
411
+
412
+ const logDir = path.join(os.homedir(), ".agentmesh", "logs");
413
+ if (!fs.existsSync(logDir)) {
414
+ fs.mkdirSync(logDir, { recursive: true });
415
+ }
416
+
417
+ this.healthCheckInterval = setInterval(async () => {
418
+ if (!this.isRunning) return;
419
+
420
+ // Reset restart count after stable operation
421
+ if (this.lastStableTime && this.restartCount > 0) {
422
+ const stableTime = Date.now() - this.lastStableTime.getTime();
423
+ if (stableTime > RESTART_COUNT_RESET_MS) {
424
+ console.log(`[HEALTH] Agent stable for 30+ minutes, resetting restart count`);
425
+ this.restartCount = 0;
426
+ resetAgentRestartCount(this.agentName);
427
+ }
428
+ }
429
+
430
+ const health = isSessionHealthy(this.agentName);
431
+
432
+ if (!health.healthy) {
433
+ // Session died - attempt restart
434
+ await this.handleSessionDeath(health.reason || "unknown", logDir);
435
+ return;
436
+ }
437
+
438
+ // Session is alive - check progress watchdog
439
+ const containerName = this.sandboxMode ? this.sandbox?.getContainerName() : undefined;
440
+ const progress = checkAgentProgress(this.agentName, containerName);
441
+
442
+ if (progress.status === "permission_blocked" || progress.status === "stuck") {
443
+ await this.handleStuckAgent(progress);
444
+ } else if (progress.status === "active") {
445
+ // Agent is working - reset stuck tracking
446
+ if (this.stuckSince) {
447
+ console.log(`[HEALTH] Agent resumed activity`);
448
+ this.stuckSince = null;
449
+ this.nudgeSentAt = null;
450
+ updateAgentInState(this.agentName, { stuckSince: undefined, status: "running" });
451
+ }
452
+ this.lastStableTime = new Date();
453
+ }
454
+ }, 60000); // Check every 60 seconds
455
+ }
456
+
457
+ /**
458
+ * Handles session death - logs crash and attempts auto-restart
459
+ */
460
+ private async handleSessionDeath(reason: string, logDir: string): Promise<void> {
461
+ const timestamp = new Date().toISOString();
462
+ const logFile = path.join(logDir, `crash-${this.agentName}.log`);
463
+
464
+ // Capture last session output before it's gone
465
+ let lastOutput = "";
466
+ try {
467
+ lastOutput = captureSessionOutput(this.agentName, 200) || "Unable to capture output";
468
+ } catch {
469
+ lastOutput = "Failed to capture session output";
470
+ }
471
+
472
+ const crashLog = `
473
+ ================================================================================
474
+ AGENT CRASH DETECTED
475
+ ================================================================================
476
+ Timestamp: ${timestamp}
477
+ Agent: ${this.agentName}
478
+ Agent ID: ${this.agentId}
479
+ Reason: ${reason}
480
+ Restart Count: ${this.restartCount}/${MAX_RESTART_ATTEMPTS}
481
+ Sandbox: ${this.sandboxMode ? this.sandbox?.getContainerName() : "none"}
482
+ Workdir: ${this.agentConfig.workdir}
483
+ Model: ${this.runnerConfig.model}
484
+
485
+ --- Last Session Output ---
486
+ ${lastOutput}
487
+ ================================================================================
488
+
489
+ `;
490
+
491
+ fs.appendFileSync(logFile, crashLog);
492
+
493
+ // Check if we can restart
494
+ if (this.restartCount < MAX_RESTART_ATTEMPTS) {
495
+ this.restartCount++;
496
+ console.error(
497
+ `[CRASH] Session died: ${reason}. Attempting restart (${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`,
498
+ );
499
+
500
+ updateAgentInState(this.agentName, {
501
+ restartCount: this.restartCount,
502
+ lastRestartAt: timestamp,
503
+ status: "running",
504
+ });
505
+
506
+ try {
507
+ await this.restartSession();
508
+ console.log(`[RESTART] Agent restarted successfully`);
509
+ this.lastStableTime = new Date();
510
+ } catch (error) {
511
+ console.error(`[RESTART] Failed to restart: ${(error as Error).message}`);
512
+ }
513
+ } else {
514
+ // Exceeded restart limit - mark as failed
515
+ console.error(
516
+ `[FAILED] Agent exceeded restart limit (${MAX_RESTART_ATTEMPTS}). Manual intervention required.`,
517
+ );
518
+
519
+ // Terminal bell to alert user
520
+ process.stdout.write("\x07");
521
+
522
+ updateAgentInState(this.agentName, {
523
+ status: "failed",
524
+ restartCount: this.restartCount,
525
+ });
526
+
527
+ // Stop monitoring
528
+ this.isRunning = false;
529
+ if (this.healthCheckInterval) {
530
+ clearInterval(this.healthCheckInterval);
531
+ this.healthCheckInterval = null;
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Handles stuck agent - sends nudge first, then restarts if still stuck
538
+ */
539
+ private async handleStuckAgent(progress: {
540
+ status: string;
541
+ blockedOn?: string;
542
+ details?: string;
543
+ }): Promise<void> {
544
+ const now = new Date();
545
+
546
+ if (!this.stuckSince) {
547
+ // First detection of stuck state
548
+ this.stuckSince = now;
549
+ console.log(
550
+ `[HEALTH] Agent appears stuck: ${progress.details || progress.blockedOn || "no activity"}`,
551
+ );
552
+
553
+ updateAgentInState(this.agentName, {
554
+ stuckSince: now.toISOString(),
555
+ status: "stuck",
556
+ });
557
+ }
558
+
559
+ // If we haven't sent a nudge yet, send one
560
+ if (!this.nudgeSentAt) {
561
+ console.log(`[HEALTH] Sending nudge to unstick agent...`);
562
+
563
+ const nudgeMessage =
564
+ progress.status === "permission_blocked"
565
+ ? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
566
+ : "Please continue with your current task.";
567
+
568
+ const sent = sendNudge(this.agentName, nudgeMessage);
569
+ if (sent) {
570
+ this.nudgeSentAt = now;
571
+ console.log(`[HEALTH] Nudge sent successfully`);
572
+ } else {
573
+ console.log(`[HEALTH] Failed to send nudge`);
574
+ }
575
+ return;
576
+ }
577
+
578
+ // Check if enough time has passed since nudge
579
+ const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
580
+ if (timeSinceNudge < NUDGE_WAIT_MS) {
581
+ // Still waiting for agent to respond to nudge
582
+ return;
583
+ }
584
+
585
+ // Agent still stuck after nudge - trigger restart
586
+ console.log(`[HEALTH] Agent still stuck after nudge, triggering restart...`);
587
+ this.stuckSince = null;
588
+ this.nudgeSentAt = null;
589
+
590
+ await this.handleSessionDeath(
591
+ "stuck_after_nudge",
592
+ path.join(os.homedir(), ".agentmesh", "logs"),
593
+ );
594
+ }
595
+
596
+ /**
597
+ * Restarts the agent session (sandbox or non-sandbox)
598
+ */
599
+ private async restartSession(): Promise<void> {
600
+ // Destroy existing session
601
+ destroySession(this.agentName);
602
+
603
+ if (this.sandboxMode && this.sandbox) {
604
+ // Restart sandbox container
605
+ const newContainerId = await this.sandbox.restart();
606
+ console.log(`[RESTART] New container: ${newContainerId.substring(0, 12)}`);
607
+
608
+ // Recreate tmux session for sandbox
609
+ const containerName = this.sandbox.getContainerName();
610
+ const sessionName = getSessionName(this.agentName);
611
+
612
+ // Build environment args for docker exec
613
+ const envArgs: string[] = [];
614
+ const allEnv = {
615
+ ...this.runnerConfig.env,
616
+ AGENT_TOKEN: this.token!,
617
+ AGENTMESH_AGENT_ID: this.agentId!,
618
+ };
619
+ for (const [key, value] of Object.entries(allEnv)) {
620
+ if (value !== undefined && value !== "") {
621
+ envArgs.push(`-e "${key}=${value}"`);
622
+ }
623
+ }
624
+ const envString = envArgs.join(" ");
625
+ const modelArg = this.runnerConfig.env?.OPENCODE_MODEL
626
+ ? ` --model ${this.runnerConfig.env.OPENCODE_MODEL}`
627
+ : "";
628
+ const dockerExecCommand = `docker exec -it ${envString} ${containerName} opencode${modelArg}`;
629
+
630
+ const created = createSession(this.agentName, dockerExecCommand, undefined, undefined);
631
+ if (!created) {
632
+ throw new Error("Failed to create tmux session for restarted sandbox");
633
+ }
634
+
635
+ // Update state with new container name
636
+ updateAgentInState(this.agentName, {
637
+ sandboxContainer: containerName,
638
+ });
639
+ } else {
640
+ // Non-sandbox restart - just recreate tmux session
641
+ const created = createSession(
642
+ this.agentName,
643
+ this.agentConfig.command,
644
+ this.agentConfig.workdir,
645
+ this.runnerConfig.env,
646
+ );
647
+
648
+ if (!created) {
649
+ throw new Error("Failed to create tmux session");
650
+ }
651
+
652
+ // Re-inject environment
653
+ updateSessionEnvironment(this.agentName, {
654
+ AGENT_TOKEN: this.token!,
655
+ AGENTMESH_AGENT_ID: this.agentId!,
656
+ ...this.runnerConfig.env,
657
+ });
658
+ }
659
+
660
+ // Wait for session to be ready
661
+ await new Promise((resolve) => setTimeout(resolve, 2000));
662
+ }
663
+
347
664
  async stop(): Promise<void> {
348
665
  console.log(`\nStopping agent: ${this.agentName}`);
349
666
 
350
667
  this.isRunning = false;
351
668
 
669
+ // Stop health monitor
670
+ if (this.healthCheckInterval) {
671
+ clearInterval(this.healthCheckInterval);
672
+ this.healthCheckInterval = null;
673
+ }
674
+
352
675
  // Save context before stopping
353
676
  if (this.agentId) {
354
677
  console.log("Saving agent context...");
@@ -369,7 +692,11 @@ Nudge agent:
369
692
 
370
693
  // Stop sandbox, serve process, or destroy tmux session
371
694
  if (this.sandboxMode && this.sandbox) {
372
- console.log("Stopping sandbox container...");
695
+ console.log("Stopping sandbox...");
696
+ // In sandbox mode, we have both a tmux session (on host) and a Docker container
697
+ // Destroy tmux session first (this stops docker exec)
698
+ destroySession(this.agentName);
699
+ // Then destroy the container
373
700
  await this.sandbox.destroy();
374
701
  this.sandbox = null;
375
702
  } else if (this.serveMode && this.serveProcess) {
@@ -446,6 +773,10 @@ Nudge agent:
446
773
  /**
447
774
  * Starts agent in Docker sandbox mode
448
775
  * Provides filesystem isolation with only workspace mounted
776
+ *
777
+ * Strategy: Start Docker container with tail -f /dev/null, then create
778
+ * a tmux session on the HOST that runs `docker exec -it <container> opencode`.
779
+ * This way tmux provides the TTY that docker exec needs.
449
780
  */
450
781
  private async startSandboxMode(): Promise<void> {
451
782
  console.log("Starting in Docker sandbox mode...");
@@ -463,10 +794,57 @@ Nudge agent:
463
794
  const existingContainer = DockerSandbox.findExisting(this.agentName);
464
795
  if (existingContainer) {
465
796
  console.log(`Found existing sandbox container: ${existingContainer}`);
466
- console.log("Stop it with: agentmesh stop " + this.agentName);
797
+ console.log(`Stop it with: agentmesh stop ${this.agentName}`);
467
798
  throw new Error("Sandbox container already exists");
468
799
  }
469
800
 
801
+ // Build additional mounts for credentials and config
802
+ // The entrypoint script copies these from /tmp/ to the correct locations
803
+ const additionalMounts: string[] = [];
804
+
805
+ // Mount git credentials
806
+ const gitCredentialsPath = path.join(os.homedir(), ".git-credentials");
807
+ if (fs.existsSync(gitCredentialsPath)) {
808
+ additionalMounts.push(`${gitCredentialsPath}:/tmp/.git-credentials-host:ro`);
809
+ }
810
+
811
+ // Mount OpenCode auth.json for API provider tokens (Anthropic, OpenAI, etc.)
812
+ const opencodeAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
813
+ if (fs.existsSync(opencodeAuthPath)) {
814
+ additionalMounts.push(`${opencodeAuthPath}:/tmp/.opencode-auth-host:ro`);
815
+ }
816
+
817
+ // Mount AgentMesh config for hub URL, API key, workspace
818
+ const agentmeshConfigPath = path.join(os.homedir(), ".agentmesh", "config.json");
819
+ if (fs.existsSync(agentmeshConfigPath)) {
820
+ additionalMounts.push(`${agentmeshConfigPath}:/tmp/.agentmesh-config-host:ro`);
821
+ }
822
+
823
+ // Create and mount permissive OpenCode config for sandbox
824
+ // This allows all permissions since the container is already sandboxed
825
+ this.ensureSandboxOpencodeConfig();
826
+ additionalMounts.push(`${SANDBOX_OPENCODE_CONFIG_PATH}:/workspace/opencode.json:ro`);
827
+
828
+ // Pass GitHub token as environment variable for git operations
829
+ const gitCredentials = fs.existsSync(gitCredentialsPath)
830
+ ? fs.readFileSync(gitCredentialsPath, "utf-8").trim()
831
+ : "";
832
+ const gitHubToken = gitCredentials.match(/github_pat_[^\s@]+/)?.[0] || "";
833
+
834
+ // Build the command to run inside the container
835
+ // The agentmesh CLI inside the container will create tmux + opencode
836
+ const model =
837
+ this.runnerConfig.env?.OPENCODE_MODEL || this.runnerConfig.model || "claude-sonnet-4";
838
+ const containerCommand = [
839
+ "agentmesh",
840
+ "start",
841
+ "--name",
842
+ this.agentName,
843
+ "--model",
844
+ model,
845
+ "--foreground",
846
+ ];
847
+
470
848
  // Create sandbox configuration
471
849
  this.sandbox = new DockerSandbox({
472
850
  agentName: this.agentName,
@@ -478,10 +856,14 @@ Nudge agent:
478
856
  ...this.runnerConfig.env,
479
857
  AGENT_TOKEN: this.token!,
480
858
  AGENTMESH_AGENT_ID: this.agentId!,
859
+ // Git credentials for pushing to GitHub
860
+ ...(gitHubToken && { GH_TOKEN: gitHubToken, GITHUB_TOKEN: gitHubToken }),
481
861
  },
482
862
  serveMode: this.serveMode,
483
863
  servePort: this.servePort,
484
864
  networkMode: "bridge",
865
+ additionalMounts: additionalMounts.length > 0 ? additionalMounts : undefined,
866
+ command: this.serveMode ? undefined : containerCommand,
485
867
  });
486
868
 
487
869
  // Validate mount policy (will throw if denied)
@@ -490,31 +872,37 @@ Nudge agent:
490
872
  // Pull image if needed
491
873
  await this.sandbox.pullImage();
492
874
 
493
- // Start container
875
+ // Start container with agentmesh running inside
876
+ // The entrypoint script sets up credentials before agentmesh starts
494
877
  await this.sandbox.start();
495
878
 
879
+ const containerName = this.sandbox.getContainerName();
880
+
496
881
  console.log(`
497
882
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
498
- 🐳 SANDBOX MODE ACTIVE
883
+ SANDBOX MODE ACTIVE
499
884
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
500
885
 
501
- Container: ${this.sandbox.getContainerName()}
886
+ Container: ${containerName}
502
887
  Image: ${this.sandboxImage}
503
888
  Workspace: ${workdir} -> /workspace
504
889
  CPU: ${this.sandboxCpu} core(s)
505
890
  Memory: ${this.sandboxMemory}
891
+ Model: ${model}
506
892
 
507
- The agent is running in an isolated Docker container.
508
- Only the workspace directory is accessible.
893
+ The agent daemon is running INSIDE the Docker container.
894
+ tmux session and OpenCode are managed inside the container.
895
+
896
+ Attach: agentmesh attach ${this.agentName}
897
+ Nudge: agentmesh nudge ${this.agentName} "message"
898
+ Stop: agentmesh stop ${this.agentName}
899
+ Logs: docker logs ${containerName}
509
900
 
510
901
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
511
902
  `);
512
903
 
513
- // Start opencode in the container
514
- if (!this.serveMode) {
515
- console.log("Starting opencode in sandbox container...");
516
- await this.sandbox.spawnOpencode();
517
- }
904
+ // No host tmux session needed - the container runs agentmesh which creates its own tmux
905
+ // Heartbeats are sent by the daemon running inside the container
518
906
  }
519
907
 
520
908
  /**
@@ -713,4 +1101,31 @@ Option 3: Use --auto-setup to automatically clone the repository:
713
1101
  console.log(`✓ Workspace ready: ${workspacePath}\n`);
714
1102
  return workspacePath;
715
1103
  }
1104
+
1105
+ /**
1106
+ * Ensures the sandbox OpenCode config exists
1107
+ * Creates ~/.agentmesh/opencode-sandbox.json with permissive permissions and model
1108
+ */
1109
+ private ensureSandboxOpencodeConfig(): void {
1110
+ const configDir = path.dirname(SANDBOX_OPENCODE_CONFIG_PATH);
1111
+
1112
+ if (!fs.existsSync(configDir)) {
1113
+ fs.mkdirSync(configDir, { recursive: true });
1114
+ }
1115
+
1116
+ // Build config with model if available
1117
+ const config: Record<string, unknown> = {
1118
+ ...SANDBOX_OPENCODE_CONFIG,
1119
+ };
1120
+
1121
+ // Include model from runner config
1122
+ const model = this.runnerConfig.env?.OPENCODE_MODEL;
1123
+ if (model) {
1124
+ config.model = model;
1125
+ }
1126
+
1127
+ // Always write to ensure model is up to date
1128
+ fs.writeFileSync(SANDBOX_OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
1129
+ console.log(`Updated sandbox OpenCode config: ${SANDBOX_OPENCODE_CONFIG_PATH}`);
1130
+ }
716
1131
  }
@@ -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,36 @@ 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
+ "claude-sonnet-4": "anthropic/claude-sonnet-4-5",
126
+ "claude-sonnet-4-5": "anthropic/claude-sonnet-4-5",
127
+ "claude-opus-4": "anthropic/claude-opus-4-5",
128
+ "claude-opus-4-5": "anthropic/claude-opus-4-5",
129
+ "claude-haiku-4": "anthropic/claude-haiku-4-5",
130
+ "claude-haiku-4-5": "anthropic/claude-haiku-4-5",
131
+ "gpt-4o": "openai/gpt-4o",
132
+ "gpt-4": "openai/gpt-4",
133
+ o3: "openai/o3",
134
+ "o3-mini": "openai/o3-mini",
135
+ codex: "openai/codex",
136
+ };
137
+
123
138
  /**
124
139
  * Normalizes a model name for OpenCode
125
- * e.g., "claude-sonnet-4" -> "anthropic/claude-sonnet-4" if that's what OpenCode expects
140
+ * e.g., "claude-sonnet-4" -> "anthropic/claude-sonnet-4-5" if that's what OpenCode expects
126
141
  */
127
142
  export function normalizeOpenCodeModel(model: string): string {
143
+ // Check hardcoded aliases first (works even if opencode not installed)
144
+ if (MODEL_ALIASES[model]) {
145
+ return MODEL_ALIASES[model];
146
+ }
147
+
148
+ // Already has provider prefix
149
+ if (model.includes("/")) {
150
+ return model;
151
+ }
152
+
128
153
  const models = getOpenCodeModels();
129
154
 
130
155
  // Direct match