@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.
- package/dist/__tests__/loader.test.js +44 -1
- package/dist/__tests__/loader.test.js.map +1 -1
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/sandbox.test.js.map +1 -1
- package/dist/__tests__/watchdog.test.d.ts +1 -0
- package/dist/__tests__/watchdog.test.js +290 -0
- package/dist/__tests__/watchdog.test.js.map +1 -0
- package/dist/cli/attach.js +20 -1
- package/dist/cli/attach.js.map +1 -1
- package/dist/cli/build.js +8 -2
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/context.js.map +1 -1
- package/dist/cli/deploy.js +1 -1
- package/dist/cli/deploy.js.map +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/list.js +3 -3
- package/dist/cli/list.js.map +1 -1
- package/dist/cli/local.js +5 -3
- package/dist/cli/local.js.map +1 -1
- package/dist/cli/migrate.js +1 -1
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/nudge.js +16 -3
- package/dist/cli/nudge.js.map +1 -1
- package/dist/cli/restart.js.map +1 -1
- package/dist/cli/slack.js +1 -1
- package/dist/cli/slack.js.map +1 -1
- package/dist/cli/stop.js +13 -5
- package/dist/cli/stop.js.map +1 -1
- package/dist/cli/test.js +1 -1
- package/dist/cli/test.js.map +1 -1
- package/dist/cli/token.js +2 -2
- package/dist/cli/token.js.map +1 -1
- package/dist/config/loader.d.ts +5 -1
- package/dist/config/loader.js +27 -2
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +13 -0
- package/dist/core/daemon.d.ts +32 -1
- package/dist/core/daemon.js +362 -19
- package/dist/core/daemon.js.map +1 -1
- package/dist/core/injector.d.ts +2 -2
- package/dist/core/injector.js +23 -4
- package/dist/core/injector.js.map +1 -1
- package/dist/core/runner.d.ts +1 -1
- package/dist/core/runner.js +23 -1
- package/dist/core/runner.js.map +1 -1
- package/dist/core/sandbox.d.ts +11 -0
- package/dist/core/sandbox.js +34 -2
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/tmux.d.ts +8 -0
- package/dist/core/tmux.js +28 -1
- package/dist/core/tmux.js.map +1 -1
- package/dist/core/watchdog.d.ts +41 -0
- package/dist/core/watchdog.js +198 -0
- package/dist/core/watchdog.js.map +1 -0
- package/dist/core/websocket.js +1 -1
- package/dist/core/websocket.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/loader.test.ts +52 -4
- package/src/__tests__/runner.test.ts +1 -2
- package/src/__tests__/sandbox.test.ts +1 -1
- package/src/__tests__/watchdog.test.ts +368 -0
- package/src/cli/attach.ts +22 -1
- package/src/cli/build.ts +12 -4
- package/src/cli/context.ts +0 -1
- package/src/cli/deploy.ts +7 -5
- package/src/cli/init.ts +7 -19
- package/src/cli/list.ts +6 -10
- package/src/cli/local.ts +21 -12
- package/src/cli/migrate.ts +6 -4
- package/src/cli/nudge.ts +29 -14
- package/src/cli/restart.ts +1 -1
- package/src/cli/slack.ts +16 -15
- package/src/cli/stop.ts +14 -5
- package/src/cli/test.ts +5 -3
- package/src/cli/token.ts +4 -4
- package/src/config/loader.ts +29 -2
- package/src/config/schema.ts +14 -0
- package/src/core/daemon.ts +439 -24
- package/src/core/injector.ts +27 -4
- package/src/core/runner.ts +26 -1
- package/src/core/sandbox.ts +47 -2
- package/src/core/tmux.ts +35 -2
- package/src/core/watchdog.ts +238 -0
- package/src/core/websocket.ts +2 -2
- package/src/index.ts +6 -5
package/src/core/daemon.ts
CHANGED
|
@@ -6,31 +6,50 @@ import {
|
|
|
6
6
|
addAgentToState,
|
|
7
7
|
getAgentState,
|
|
8
8
|
loadConfig,
|
|
9
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
883
|
+
SANDBOX MODE ACTIVE
|
|
499
884
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
500
885
|
|
|
501
|
-
Container: ${
|
|
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
|
|
508
|
-
|
|
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
|
-
//
|
|
514
|
-
|
|
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
|
}
|
package/src/core/injector.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
110
|
+
_context?: EventContext,
|
|
88
111
|
): void {
|
|
89
112
|
const user = (event.user as string) || "unknown";
|
|
90
113
|
const channel = (event.channel as string) || "unknown";
|
package/src/core/runner.ts
CHANGED
|
@@ -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
|