@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
@@ -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";
@@ -8,11 +8,17 @@ export interface RegisterOptions {
8
8
  agentName: string;
9
9
  model: string;
10
10
  capabilities?: string[];
11
+ restoreContext?: boolean;
11
12
  }
12
13
 
14
+ export type ServerContext = Record<string, Record<string, unknown>>;
15
+
13
16
  export interface RegisterResult {
14
17
  agentId: string;
15
18
  token: string;
19
+ status?: "registered" | "re-registered";
20
+ context?: ServerContext;
21
+ assignments?: ProjectAssignment[];
16
22
  }
17
23
 
18
24
  export interface InboxItem {
@@ -42,6 +48,7 @@ export async function registerAgent(options: RegisterOptions): Promise<RegisterR
42
48
  model: options.model,
43
49
  capabilities: options.capabilities || ["coding", "review", "debugging"],
44
50
  workspace: options.workspace,
51
+ restore_context: options.restoreContext ?? true,
45
52
  }),
46
53
  });
47
54
 
@@ -57,7 +64,13 @@ export async function registerAgent(options: RegisterOptions): Promise<RegisterR
57
64
  throw new Error("No token in registration response");
58
65
  }
59
66
 
60
- return { agentId, token };
67
+ return {
68
+ agentId,
69
+ token,
70
+ status: data.status,
71
+ context: data.context,
72
+ assignments: data.assignments,
73
+ };
61
74
  }
62
75
 
63
76
  export async function checkInbox(
@@ -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
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Docker Sandbox Runtime
3
+ *
4
+ * Provides containerized execution of agents with filesystem isolation.
5
+ * Each agent runs in its own container with only the allowed workspace mounted.
6
+ */
7
+
8
+ import { type ChildProcess, execSync, spawn, spawnSync } from "node:child_process";
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ export interface SandboxConfig {
15
+ /** Agent name (used for container naming) */
16
+ agentName: string;
17
+ /** Image to use for the container */
18
+ image: string;
19
+ /** Workspace path on host to mount */
20
+ workspacePath: string;
21
+ /** Container mount point for workspace (default: /workspace) */
22
+ containerWorkdir?: string;
23
+ /** CPU limit (e.g., "0.5" for half a CPU) */
24
+ cpuLimit?: string;
25
+ /** Memory limit (e.g., "512m", "1g") */
26
+ memoryLimit?: string;
27
+ /** Environment variables to pass to container */
28
+ env?: Record<string, string>;
29
+ /** Network mode (default: bridge) */
30
+ networkMode?: string;
31
+ /** Additional mounts (host:container format) */
32
+ additionalMounts?: string[];
33
+ /** Run opencode serve instead of TUI */
34
+ serveMode?: boolean;
35
+ /** Port for serve mode */
36
+ servePort?: number;
37
+ /** Custom command to run in container (overrides default) */
38
+ command?: string[];
39
+ }
40
+
41
+ export interface SandboxMountPolicy {
42
+ /** Paths that are allowed to be mounted */
43
+ allowedPaths: string[];
44
+ /** Paths that are explicitly denied (checked first) */
45
+ deniedPaths: string[];
46
+ }
47
+
48
+ // Default mount policy - deny sensitive paths
49
+ const DEFAULT_DENY_PATHS = [
50
+ "~/.ssh",
51
+ "~/.gnupg",
52
+ "~/.aws",
53
+ "~/.config/gcloud",
54
+ "~/.kube",
55
+ "~/.docker",
56
+ "/etc/passwd",
57
+ "/etc/shadow",
58
+ "/etc/hosts",
59
+ "/var/run/docker.sock",
60
+ ];
61
+
62
+ export class DockerSandbox {
63
+ private config: SandboxConfig;
64
+ private containerId: string | null = null;
65
+ private containerName: string;
66
+ private process: ChildProcess | null = null;
67
+
68
+ constructor(config: SandboxConfig) {
69
+ this.config = {
70
+ containerWorkdir: "/workspace",
71
+ cpuLimit: "1",
72
+ memoryLimit: "2g",
73
+ networkMode: "bridge",
74
+ ...config,
75
+ };
76
+ // Generate unique container name
77
+ const suffix = crypto.randomBytes(4).toString("hex");
78
+ this.containerName = `agentmesh-sandbox-${config.agentName}-${suffix}`;
79
+ }
80
+
81
+ /**
82
+ * Validates that the workspace path is allowed based on policy
83
+ */
84
+ validateMountPolicy(policy?: SandboxMountPolicy): void {
85
+ const workspacePath = this.expandPath(this.config.workspacePath);
86
+
87
+ // Check denied paths first
88
+ const deniedPaths = policy?.deniedPaths || DEFAULT_DENY_PATHS;
89
+ for (const denied of deniedPaths) {
90
+ const expandedDenied = this.expandPath(denied);
91
+ if (workspacePath.startsWith(expandedDenied) || workspacePath === expandedDenied) {
92
+ throw new Error(`Mount denied: ${workspacePath} matches denied path ${denied}`);
93
+ }
94
+ }
95
+
96
+ // Check allowed paths if specified
97
+ if (policy?.allowedPaths && policy.allowedPaths.length > 0) {
98
+ const isAllowed = policy.allowedPaths.some((allowed) => {
99
+ const expandedAllowed = this.expandPath(allowed);
100
+ return workspacePath.startsWith(expandedAllowed);
101
+ });
102
+ if (!isAllowed) {
103
+ throw new Error(`Mount denied: ${workspacePath} is not in allowed paths`);
104
+ }
105
+ }
106
+
107
+ // Verify path exists
108
+ if (!fs.existsSync(workspacePath)) {
109
+ throw new Error(`Workspace path does not exist: ${workspacePath}`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Expands ~ to home directory
115
+ */
116
+ private expandPath(p: string): string {
117
+ if (p.startsWith("~")) {
118
+ return path.join(os.homedir(), p.slice(1));
119
+ }
120
+ return path.resolve(p);
121
+ }
122
+
123
+ /**
124
+ * Checks if Docker is available
125
+ */
126
+ static checkDockerAvailable(): boolean {
127
+ try {
128
+ const result = spawnSync("docker", ["--version"], {
129
+ encoding: "utf-8",
130
+ timeout: 5000,
131
+ });
132
+ return result.status === 0;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Pulls the sandbox image if not present
140
+ */
141
+ async pullImage(): Promise<void> {
142
+ console.log(`Checking for image: ${this.config.image}`);
143
+
144
+ // Check if image exists locally
145
+ const result = spawnSync("docker", ["image", "inspect", this.config.image], {
146
+ encoding: "utf-8",
147
+ });
148
+
149
+ if (result.status === 0) {
150
+ console.log("Image found locally");
151
+ return;
152
+ }
153
+
154
+ console.log(`Pulling image: ${this.config.image}...`);
155
+ const pullResult = spawnSync("docker", ["pull", this.config.image], {
156
+ encoding: "utf-8",
157
+ stdio: "inherit",
158
+ });
159
+
160
+ if (pullResult.status !== 0) {
161
+ throw new Error(`Failed to pull image: ${this.config.image}`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Creates and starts the sandbox container
167
+ */
168
+ async start(): Promise<string> {
169
+ // Validate mount policy
170
+ this.validateMountPolicy();
171
+
172
+ const workspacePath = this.expandPath(this.config.workspacePath);
173
+
174
+ // Build docker run arguments
175
+ const args: string[] = [
176
+ "run",
177
+ "-d", // Detached
178
+ "--name",
179
+ this.containerName,
180
+ "--hostname",
181
+ `sandbox-${this.config.agentName}`,
182
+
183
+ // Resource limits
184
+ "--cpus",
185
+ this.config.cpuLimit!,
186
+ "--memory",
187
+ this.config.memoryLimit!,
188
+
189
+ // Security: run as non-root
190
+ "--user",
191
+ "1000:1000",
192
+
193
+ // Network
194
+ "--network",
195
+ this.config.networkMode!,
196
+
197
+ // Mount workspace
198
+ "-v",
199
+ `${workspacePath}:${this.config.containerWorkdir}:rw`,
200
+
201
+ // Working directory
202
+ "-w",
203
+ this.config.containerWorkdir!,
204
+ ];
205
+
206
+ // Add environment variables
207
+ if (this.config.env) {
208
+ for (const [key, value] of Object.entries(this.config.env)) {
209
+ args.push("-e", `${key}=${value}`);
210
+ }
211
+ }
212
+
213
+ // Add additional mounts
214
+ if (this.config.additionalMounts) {
215
+ for (const mount of this.config.additionalMounts) {
216
+ args.push("-v", mount);
217
+ }
218
+ }
219
+
220
+ // Expose port for serve mode
221
+ if (this.config.serveMode && this.config.servePort) {
222
+ args.push("-p", `${this.config.servePort}:${this.config.servePort}`);
223
+ }
224
+
225
+ // Image and command
226
+ args.push(this.config.image);
227
+
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) {
233
+ args.push(
234
+ "opencode",
235
+ "serve",
236
+ "--port",
237
+ String(this.config.servePort || 3001),
238
+ "--hostname",
239
+ "0.0.0.0",
240
+ );
241
+ } else {
242
+ // Keep container running for attach capability
243
+ args.push("tail", "-f", "/dev/null");
244
+ }
245
+
246
+ console.log(`Starting sandbox container: ${this.containerName}`);
247
+
248
+ const result = spawnSync("docker", args, {
249
+ encoding: "utf-8",
250
+ });
251
+
252
+ if (result.status !== 0) {
253
+ throw new Error(`Failed to start container: ${result.stderr || result.stdout}`);
254
+ }
255
+
256
+ this.containerId = result.stdout.trim();
257
+ console.log(`Container started: ${this.containerId.substring(0, 12)}`);
258
+
259
+ return this.containerId;
260
+ }
261
+
262
+ /**
263
+ * Executes a command in the running container
264
+ */
265
+ async exec(command: string[], options?: { interactive?: boolean }): Promise<string> {
266
+ if (!this.containerId && !this.containerName) {
267
+ throw new Error("Container not started");
268
+ }
269
+
270
+ const target = this.containerId || this.containerName;
271
+ const args = ["exec"];
272
+
273
+ if (options?.interactive) {
274
+ args.push("-it");
275
+ }
276
+
277
+ args.push(target, ...command);
278
+
279
+ const result = spawnSync("docker", args, {
280
+ encoding: "utf-8",
281
+ stdio: options?.interactive ? "inherit" : "pipe",
282
+ });
283
+
284
+ if (result.status !== 0 && !options?.interactive) {
285
+ throw new Error(`Exec failed: ${result.stderr || result.stdout}`);
286
+ }
287
+
288
+ return result.stdout || "";
289
+ }
290
+
291
+ /**
292
+ * Spawns opencode in the container (for non-serve mode)
293
+ */
294
+ async spawnOpencode(): Promise<ChildProcess> {
295
+ if (!this.containerId && !this.containerName) {
296
+ throw new Error("Container not started");
297
+ }
298
+
299
+ const target = this.containerId || this.containerName;
300
+
301
+ // Build environment args
302
+ const envArgs: string[] = [];
303
+ if (this.config.env) {
304
+ for (const [key, value] of Object.entries(this.config.env)) {
305
+ envArgs.push("-e", `${key}=${value}`);
306
+ }
307
+ }
308
+
309
+ this.process = spawn("docker", ["exec", "-it", ...envArgs, target, "opencode"], {
310
+ stdio: "inherit",
311
+ });
312
+
313
+ return this.process;
314
+ }
315
+
316
+ /**
317
+ * Attaches to the container's opencode process
318
+ */
319
+ attach(): void {
320
+ if (!this.containerId && !this.containerName) {
321
+ throw new Error("Container not started");
322
+ }
323
+
324
+ const target = this.containerId || this.containerName;
325
+
326
+ // Exec into container interactively
327
+ execSync(`docker exec -it ${target} opencode`, {
328
+ stdio: "inherit",
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Gets container logs
334
+ */
335
+ getLogs(lines = 100): string {
336
+ if (!this.containerId && !this.containerName) {
337
+ throw new Error("Container not started");
338
+ }
339
+
340
+ const target = this.containerId || this.containerName;
341
+ const result = spawnSync("docker", ["logs", "--tail", String(lines), target], {
342
+ encoding: "utf-8",
343
+ });
344
+
345
+ return result.stdout + result.stderr;
346
+ }
347
+
348
+ /**
349
+ * Follows container logs
350
+ */
351
+ followLogs(): void {
352
+ if (!this.containerId && !this.containerName) {
353
+ throw new Error("Container not started");
354
+ }
355
+
356
+ const target = this.containerId || this.containerName;
357
+ execSync(`docker logs -f ${target}`, { stdio: "inherit" });
358
+ }
359
+
360
+ /**
361
+ * Checks if container is running
362
+ */
363
+ isRunning(): boolean {
364
+ if (!this.containerId && !this.containerName) {
365
+ return false;
366
+ }
367
+
368
+ const target = this.containerId || this.containerName;
369
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Running}}", target], {
370
+ encoding: "utf-8",
371
+ });
372
+
373
+ return result.stdout.trim() === "true";
374
+ }
375
+
376
+ /**
377
+ * Gets container status
378
+ */
379
+ getStatus(): { running: boolean; status: string; health?: string } {
380
+ if (!this.containerId && !this.containerName) {
381
+ return { running: false, status: "not created" };
382
+ }
383
+
384
+ const target = this.containerId || this.containerName;
385
+ const result = spawnSync(
386
+ "docker",
387
+ ["inspect", "-f", "{{.State.Running}}|{{.State.Status}}|{{.State.Health.Status}}", target],
388
+ { encoding: "utf-8" },
389
+ );
390
+
391
+ if (result.status !== 0) {
392
+ return { running: false, status: "not found" };
393
+ }
394
+
395
+ const [running, status, health] = result.stdout.trim().split("|");
396
+ return {
397
+ running: running === "true",
398
+ status,
399
+ health: health || undefined,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Stops the container
405
+ */
406
+ async stop(): Promise<void> {
407
+ if (!this.containerId && !this.containerName) {
408
+ return;
409
+ }
410
+
411
+ const target = this.containerId || this.containerName;
412
+ console.log(`Stopping container: ${target}`);
413
+
414
+ spawnSync("docker", ["stop", "-t", "10", target], {
415
+ encoding: "utf-8",
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Removes the container
421
+ */
422
+ async destroy(): Promise<void> {
423
+ if (!this.containerId && !this.containerName) {
424
+ return;
425
+ }
426
+
427
+ const target = this.containerId || this.containerName;
428
+ console.log(`Destroying container: ${target}`);
429
+
430
+ // Stop if running
431
+ await this.stop();
432
+
433
+ // Remove
434
+ spawnSync("docker", ["rm", "-f", target], {
435
+ encoding: "utf-8",
436
+ });
437
+
438
+ this.containerId = null;
439
+ }
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
+
481
+ /**
482
+ * Gets container name
483
+ */
484
+ getContainerName(): string {
485
+ return this.containerName;
486
+ }
487
+
488
+ /**
489
+ * Gets container ID
490
+ */
491
+ getContainerId(): string | null {
492
+ return this.containerId;
493
+ }
494
+
495
+ /**
496
+ * Finds existing sandbox container for an agent
497
+ */
498
+ static findExisting(agentName: string): string | null {
499
+ const result = spawnSync(
500
+ "docker",
501
+ [
502
+ "ps",
503
+ "-aq",
504
+ "--filter",
505
+ `name=agentmesh-sandbox-${agentName}-`,
506
+ "--filter",
507
+ "status=running",
508
+ ],
509
+ { encoding: "utf-8" },
510
+ );
511
+
512
+ const containerId = result.stdout.trim().split("\n")[0];
513
+ return containerId || null;
514
+ }
515
+
516
+ /**
517
+ * Lists all sandbox containers
518
+ */
519
+ static listAll(): Array<{
520
+ id: string;
521
+ name: string;
522
+ status: string;
523
+ image: string;
524
+ }> {
525
+ const result = spawnSync(
526
+ "docker",
527
+ [
528
+ "ps",
529
+ "-a",
530
+ "--filter",
531
+ "name=agentmesh-sandbox-",
532
+ "--format",
533
+ "{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}",
534
+ ],
535
+ { encoding: "utf-8" },
536
+ );
537
+
538
+ if (result.status !== 0 || !result.stdout.trim()) {
539
+ return [];
540
+ }
541
+
542
+ return result.stdout
543
+ .trim()
544
+ .split("\n")
545
+ .map((line) => {
546
+ const [id, name, status, image] = line.split("|");
547
+ return { id, name, status, image };
548
+ });
549
+ }
550
+ }