@agentmeshhq/agent 0.1.17 → 0.2.1
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/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/__tests__/orphan-process.test.d.ts +11 -0
- package/dist/__tests__/orphan-process.test.js +286 -0
- package/dist/__tests__/orphan-process.test.js.map +1 -0
- package/dist/__tests__/runner.test.js +16 -0
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/watchdog.test.js +138 -12
- package/dist/__tests__/watchdog.test.js.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/start.d.ts +2 -1
- package/dist/cli/start.js +6 -3
- package/dist/cli/start.js.map +1 -1
- package/dist/cli/status.js +11 -0
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/stop.js +7 -2
- package/dist/cli/stop.js.map +1 -1
- package/dist/config/schema.d.ts +4 -2
- package/dist/core/daemon/assignment-message.d.ts +12 -0
- package/dist/core/daemon/assignment-message.js +36 -0
- package/dist/core/daemon/assignment-message.js.map +1 -0
- package/dist/core/daemon/bootstrap.d.ts +35 -0
- package/dist/core/daemon/bootstrap.js +52 -0
- package/dist/core/daemon/bootstrap.js.map +1 -0
- package/dist/core/daemon/crash-log.d.ts +16 -0
- package/dist/core/daemon/crash-log.js +24 -0
- package/dist/core/daemon/crash-log.js.map +1 -0
- package/dist/core/daemon/health-policy.d.ts +21 -0
- package/dist/core/daemon/health-policy.js +32 -0
- package/dist/core/daemon/health-policy.js.map +1 -0
- package/dist/core/daemon/sandbox-config.d.ts +9 -0
- package/dist/core/daemon/sandbox-config.js +17 -0
- package/dist/core/daemon/sandbox-config.js.map +1 -0
- package/dist/core/daemon/state.d.ts +33 -0
- package/dist/core/daemon/state.js +77 -0
- package/dist/core/daemon/state.js.map +1 -0
- package/dist/core/daemon/tmux-session.d.ts +17 -0
- package/dist/core/daemon/tmux-session.js +34 -0
- package/dist/core/daemon/tmux-session.js.map +1 -0
- package/dist/core/daemon/workspace.d.ts +10 -0
- package/dist/core/daemon/workspace.js +51 -0
- package/dist/core/daemon/workspace.js.map +1 -0
- package/dist/core/daemon.d.ts +4 -7
- package/dist/core/daemon.js +143 -259
- package/dist/core/daemon.js.map +1 -1
- package/dist/core/injector.js +6 -0
- package/dist/core/injector.js.map +1 -1
- package/dist/core/registry.js +1 -1
- package/dist/core/registry.js.map +1 -1
- package/dist/core/runner/build.d.ts +9 -0
- package/dist/core/runner/build.js +53 -0
- package/dist/core/runner/build.js.map +1 -0
- package/dist/core/runner/detect.d.ts +5 -0
- package/dist/core/runner/detect.js +14 -0
- package/dist/core/runner/detect.js.map +1 -0
- package/dist/core/runner/index.d.ts +5 -0
- package/dist/core/runner/index.js +5 -0
- package/dist/core/runner/index.js.map +1 -0
- package/dist/core/runner/model.d.ts +5 -0
- package/dist/core/runner/model.js +7 -0
- package/dist/core/runner/model.js.map +1 -0
- package/dist/core/runner/opencode-models.d.ts +15 -0
- package/dist/core/runner/opencode-models.js +70 -0
- package/dist/core/runner/opencode-models.js.map +1 -0
- package/dist/core/runner/types.d.ts +19 -0
- package/dist/core/runner/types.js +8 -0
- package/dist/core/runner/types.js.map +1 -0
- package/dist/core/runner.d.ts +5 -47
- package/dist/core/runner.js +5 -167
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tmux-runtime.d.ts +13 -0
- package/dist/core/tmux-runtime.js +72 -0
- package/dist/core/tmux-runtime.js.map +1 -0
- package/dist/core/tmux.d.ts +7 -1
- package/dist/core/tmux.js +75 -45
- package/dist/core/tmux.js.map +1 -1
- package/dist/core/watchdog.d.ts +18 -1
- package/dist/core/watchdog.js +78 -29
- package/dist/core/watchdog.js.map +1 -1
- package/package.json +30 -11
- package/dist/cli/inbox.d.ts +0 -5
- package/dist/cli/inbox.js +0 -123
- package/dist/cli/inbox.js.map +0 -1
- package/dist/cli/issue.d.ts +0 -42
- package/dist/cli/issue.js +0 -297
- package/dist/cli/issue.js.map +0 -1
- package/dist/cli/ready.d.ts +0 -5
- package/dist/cli/ready.js +0 -131
- package/dist/cli/ready.js.map +0 -1
- package/dist/cli/sync.d.ts +0 -8
- package/dist/cli/sync.js +0 -154
- package/dist/cli/sync.js.map +0 -1
- package/dist/core/issue-cache.d.ts +0 -44
- package/dist/core/issue-cache.js +0 -75
- package/dist/core/issue-cache.js.map +0 -1
- package/src/__tests__/context.test.ts +0 -464
- package/src/__tests__/injector.test.ts +0 -29
- package/src/__tests__/jwt.test.ts +0 -112
- package/src/__tests__/loader.test.ts +0 -239
- package/src/__tests__/runner.test.ts +0 -104
- package/src/__tests__/sandbox.test.ts +0 -435
- package/src/__tests__/watchdog.test.ts +0 -368
- package/src/cli/attach.ts +0 -22
- package/src/cli/build.ts +0 -145
- package/src/cli/config.ts +0 -148
- package/src/cli/context.ts +0 -231
- package/src/cli/deploy.ts +0 -155
- package/src/cli/index.ts +0 -375
- package/src/cli/init.ts +0 -75
- package/src/cli/list.ts +0 -70
- package/src/cli/local.ts +0 -183
- package/src/cli/logs.ts +0 -64
- package/src/cli/migrate.ts +0 -212
- package/src/cli/nudge.ts +0 -81
- package/src/cli/restart.ts +0 -59
- package/src/cli/slack.ts +0 -70
- package/src/cli/start.ts +0 -115
- package/src/cli/status.ts +0 -91
- package/src/cli/stop.ts +0 -48
- package/src/cli/test.ts +0 -143
- package/src/cli/token.ts +0 -188
- package/src/cli/whoami.ts +0 -142
- package/src/config/loader.ts +0 -121
- package/src/config/schema.ts +0 -68
- package/src/context/handoff.ts +0 -122
- package/src/context/index.ts +0 -8
- package/src/context/schema.ts +0 -111
- package/src/context/storage.ts +0 -197
- package/src/core/daemon.ts +0 -1308
- package/src/core/heartbeat.ts +0 -129
- package/src/core/injector.ts +0 -292
- package/src/core/registry.ts +0 -159
- package/src/core/runner.ts +0 -225
- package/src/core/sandbox.ts +0 -547
- package/src/core/session-id.ts +0 -111
- package/src/core/tmux.ts +0 -405
- package/src/core/watchdog.ts +0 -238
- package/src/core/websocket.ts +0 -94
- package/src/index.ts +0 -10
- package/src/utils/jwt.ts +0 -87
- package/tsconfig.json +0 -8
- package/vitest.config.ts +0 -12
package/src/core/daemon.ts
DELETED
|
@@ -1,1308 +0,0 @@
|
|
|
1
|
-
import { type ChildProcess, execSync, spawn } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
addAgentToState,
|
|
7
|
-
getAgentState,
|
|
8
|
-
loadConfig,
|
|
9
|
-
resetAgentRestartCount,
|
|
10
|
-
updateAgentInState,
|
|
11
|
-
} from "../config/loader.js";
|
|
12
|
-
import type { AgentConfig, AgentStatus, Config } from "../config/schema.js";
|
|
13
|
-
import { loadContext, loadOrCreateContext, saveContext } from "../context/index.js";
|
|
14
|
-
import { Heartbeat } from "./heartbeat.js";
|
|
15
|
-
import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } from "./injector.js";
|
|
16
|
-
import { checkInbox, fetchAssignments, registerAgent, type ServerContext } from "./registry.js";
|
|
17
|
-
import { buildRunnerConfig, getRunnerDisplayName, type RunnerConfig } from "./runner.js";
|
|
18
|
-
import { DockerSandbox } from "./sandbox.js";
|
|
19
|
-
import { getLatestSessionId, snapshotSessionId, waitForNewSessionId } from "./session-id.js";
|
|
20
|
-
import {
|
|
21
|
-
captureSessionContext,
|
|
22
|
-
captureSessionOutput,
|
|
23
|
-
createSession,
|
|
24
|
-
destroySession,
|
|
25
|
-
getSessionName,
|
|
26
|
-
isSessionHealthy,
|
|
27
|
-
sessionExists,
|
|
28
|
-
updateSessionEnvironment,
|
|
29
|
-
} from "./tmux.js";
|
|
30
|
-
import {
|
|
31
|
-
checkAgentProgress,
|
|
32
|
-
cleanupOrphanContainers,
|
|
33
|
-
isProcessRunning,
|
|
34
|
-
sendNudge,
|
|
35
|
-
} from "./watchdog.js";
|
|
36
|
-
import { AgentWebSocket } from "./websocket.js";
|
|
37
|
-
|
|
38
|
-
// Maximum number of auto-restart attempts
|
|
39
|
-
const MAX_RESTART_ATTEMPTS = 3;
|
|
40
|
-
// Time after which restart count resets (30 minutes of stable operation)
|
|
41
|
-
const RESTART_COUNT_RESET_MS = 30 * 60 * 1000;
|
|
42
|
-
// Time to wait after nudging before restarting (2 minutes)
|
|
43
|
-
const NUDGE_WAIT_MS = 2 * 60 * 1000;
|
|
44
|
-
|
|
45
|
-
// Path to the sandbox OpenCode config (permissive permissions)
|
|
46
|
-
const SANDBOX_OPENCODE_CONFIG_PATH = path.join(os.homedir(), ".agentmesh", "opencode-sandbox.json");
|
|
47
|
-
|
|
48
|
-
// Sandbox OpenCode config content - allow everything since container is sandboxed
|
|
49
|
-
const SANDBOX_OPENCODE_CONFIG = {
|
|
50
|
-
$schema: "https://opencode.ai/config.json",
|
|
51
|
-
permission: "allow",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export interface DaemonOptions {
|
|
55
|
-
name: string;
|
|
56
|
-
command?: string;
|
|
57
|
-
workdir?: string;
|
|
58
|
-
model?: string;
|
|
59
|
-
daemonize?: boolean;
|
|
60
|
-
/** Whether to restore context from previous session (default: true) */
|
|
61
|
-
restoreContext?: boolean;
|
|
62
|
-
/** Auto-clone repository for project assignments */
|
|
63
|
-
autoSetup?: boolean;
|
|
64
|
-
/** Run opencode serve instead of tmux TUI (for Integration Service) */
|
|
65
|
-
serve?: boolean;
|
|
66
|
-
/** Port for opencode serve (default: 3001) */
|
|
67
|
-
servePort?: number;
|
|
68
|
-
/** Run agent in Docker sandbox container */
|
|
69
|
-
sandbox?: boolean;
|
|
70
|
-
/** Docker image for sandbox (default: agentmesh/agent-sandbox:latest) */
|
|
71
|
-
sandboxImage?: string;
|
|
72
|
-
/** CPU limit for sandbox (default: 1) */
|
|
73
|
-
sandboxCpu?: string;
|
|
74
|
-
/** Memory limit for sandbox (default: 2g) */
|
|
75
|
-
sandboxMemory?: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export class AgentDaemon {
|
|
79
|
-
private agentName: string;
|
|
80
|
-
private config: Config;
|
|
81
|
-
private agentConfig: AgentConfig;
|
|
82
|
-
private runnerConfig: RunnerConfig;
|
|
83
|
-
private ws: AgentWebSocket | null = null;
|
|
84
|
-
private heartbeat: Heartbeat | null = null;
|
|
85
|
-
private token: string | null = null;
|
|
86
|
-
private agentId: string | null = null;
|
|
87
|
-
private isRunning = false;
|
|
88
|
-
private assignedProject: string | undefined;
|
|
89
|
-
private shouldRestoreContext: boolean;
|
|
90
|
-
private autoSetup: boolean;
|
|
91
|
-
private serveMode: boolean;
|
|
92
|
-
private servePort: number;
|
|
93
|
-
private serveProcess: ChildProcess | null = null;
|
|
94
|
-
private sandboxMode: boolean;
|
|
95
|
-
private sandboxImage: string;
|
|
96
|
-
private sandboxCpu: string;
|
|
97
|
-
private sandboxMemory: string;
|
|
98
|
-
private sandbox: DockerSandbox | null = null;
|
|
99
|
-
private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
100
|
-
private serverContext: ServerContext | undefined;
|
|
101
|
-
// Session resume tracking
|
|
102
|
-
private _preStartSessionId: string | null | undefined;
|
|
103
|
-
private _attemptedResumeSessionId: string | undefined;
|
|
104
|
-
// Auto-restart tracking
|
|
105
|
-
private restartCount = 0;
|
|
106
|
-
private lastStableTime: Date | null = null;
|
|
107
|
-
private stuckSince: Date | null = null;
|
|
108
|
-
private nudgeSentAt: Date | null = null;
|
|
109
|
-
|
|
110
|
-
constructor(options: DaemonOptions) {
|
|
111
|
-
const config = loadConfig();
|
|
112
|
-
if (!config) {
|
|
113
|
-
throw new Error("No config found. Run 'agentmesh init' first.");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Ensure config has required fields with defaults
|
|
117
|
-
if (!config.agents) config.agents = [];
|
|
118
|
-
if (!config.defaults) config.defaults = { command: "opencode", model: "claude-sonnet-4" };
|
|
119
|
-
|
|
120
|
-
this.config = config;
|
|
121
|
-
this.agentName = options.name;
|
|
122
|
-
this.shouldRestoreContext = options.restoreContext !== false;
|
|
123
|
-
this.autoSetup = options.autoSetup === true;
|
|
124
|
-
|
|
125
|
-
// Find or create agent config
|
|
126
|
-
let agentConfig = config.agents.find((a) => a.name === options.name);
|
|
127
|
-
|
|
128
|
-
if (!agentConfig) {
|
|
129
|
-
agentConfig = {
|
|
130
|
-
name: options.name,
|
|
131
|
-
command: options.command || config.defaults.command,
|
|
132
|
-
workdir: options.workdir,
|
|
133
|
-
model: options.model || config.defaults.model,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Override with provided options
|
|
138
|
-
if (options.command) agentConfig.command = options.command;
|
|
139
|
-
if (options.workdir) agentConfig.workdir = options.workdir;
|
|
140
|
-
if (options.model) agentConfig.model = options.model;
|
|
141
|
-
|
|
142
|
-
this.agentConfig = agentConfig;
|
|
143
|
-
this.serveMode = options.serve === true;
|
|
144
|
-
this.servePort = options.servePort || 3001;
|
|
145
|
-
this.sandboxMode = options.sandbox === true;
|
|
146
|
-
this.sandboxImage = options.sandboxImage || "agentmesh/agent-sandbox:latest";
|
|
147
|
-
this.sandboxCpu = options.sandboxCpu || "1";
|
|
148
|
-
this.sandboxMemory = options.sandboxMemory || "2g";
|
|
149
|
-
|
|
150
|
-
// Build runner configuration with model resolution
|
|
151
|
-
this.runnerConfig = buildRunnerConfig({
|
|
152
|
-
cliModel: options.model,
|
|
153
|
-
agentModel: agentConfig.model,
|
|
154
|
-
defaultModel: config.defaults.model,
|
|
155
|
-
command: agentConfig.command,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
const runnerName = getRunnerDisplayName(this.runnerConfig.type);
|
|
159
|
-
console.log(`Runner: ${runnerName}`);
|
|
160
|
-
console.log(`Effective model: ${this.runnerConfig.model}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async start(): Promise<void> {
|
|
164
|
-
if (this.isRunning) {
|
|
165
|
-
console.error("Daemon already running");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
console.log(`Starting agent: ${this.agentName}`);
|
|
170
|
-
|
|
171
|
-
// Check for duplicate process
|
|
172
|
-
const existingState = getAgentState(this.agentName);
|
|
173
|
-
if (existingState && existingState.pid > 0) {
|
|
174
|
-
if (isProcessRunning(existingState.pid)) {
|
|
175
|
-
throw new Error(
|
|
176
|
-
`Agent "${this.agentName}" is already running (PID: ${existingState.pid}). ` +
|
|
177
|
-
`Use 'agentmesh stop ${this.agentName}' first.`,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
// Process not running, clean up stale state
|
|
181
|
-
console.log(`Cleaning up stale state for PID ${existingState.pid}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Clean up orphan containers in sandbox mode
|
|
185
|
-
if (this.sandboxMode) {
|
|
186
|
-
const cleaned = cleanupOrphanContainers(this.agentName);
|
|
187
|
-
if (cleaned > 0) {
|
|
188
|
-
console.log(`Cleaned up ${cleaned} orphan container(s)`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Reset restart count on manual start
|
|
193
|
-
this.restartCount = 0;
|
|
194
|
-
this.lastStableTime = new Date();
|
|
195
|
-
|
|
196
|
-
// Register with hub first (needed for assignment check)
|
|
197
|
-
console.log("Registering with AgentMesh hub...");
|
|
198
|
-
console.log(`Existing state: ${existingState ? `agentId=${existingState.agentId}` : "none"}`);
|
|
199
|
-
|
|
200
|
-
const registration = await registerAgent({
|
|
201
|
-
url: this.config.hubUrl,
|
|
202
|
-
apiKey: this.config.apiKey,
|
|
203
|
-
workspace: this.config.workspace,
|
|
204
|
-
agentId: existingState?.agentId || this.agentConfig.agentId,
|
|
205
|
-
agentName: this.agentName,
|
|
206
|
-
model: this.agentConfig.model || this.config.defaults.model,
|
|
207
|
-
restoreContext: this.shouldRestoreContext,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
this.agentId = registration.agentId;
|
|
211
|
-
this.token = registration.token;
|
|
212
|
-
|
|
213
|
-
if (registration.status === "re-registered") {
|
|
214
|
-
console.log(`Re-registered as: ${this.agentId}`);
|
|
215
|
-
if (registration.context && Object.keys(registration.context).length > 0) {
|
|
216
|
-
this.serverContext = registration.context;
|
|
217
|
-
console.log(`Server context restored: ${Object.keys(registration.context).join(", ")}`);
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
console.log(`Registered as: ${this.agentId}`);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Check assignments and auto-setup workdir if needed (before creating tmux session)
|
|
224
|
-
await this.checkAssignments();
|
|
225
|
-
|
|
226
|
-
// Choose runtime mode: sandbox > serve > tmux
|
|
227
|
-
if (this.sandboxMode) {
|
|
228
|
-
await this.startSandboxMode();
|
|
229
|
-
} else if (this.serveMode) {
|
|
230
|
-
await this.startServeMode();
|
|
231
|
-
} else {
|
|
232
|
-
// Check if session already exists
|
|
233
|
-
const sessionName = getSessionName(this.agentName);
|
|
234
|
-
const sessionAlreadyExists = sessionExists(sessionName);
|
|
235
|
-
|
|
236
|
-
// Create tmux session if it doesn't exist
|
|
237
|
-
if (!sessionAlreadyExists) {
|
|
238
|
-
// Load saved context to check for OpenCode session ID (for native resume)
|
|
239
|
-
let savedSessionId: string | undefined;
|
|
240
|
-
if (this.shouldRestoreContext && this.agentId) {
|
|
241
|
-
const savedContext = loadContext(this.agentId);
|
|
242
|
-
savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
|
|
243
|
-
if (savedSessionId) {
|
|
244
|
-
console.log(`[CONTEXT] Found saved OpenCode session ID: ${savedSessionId}`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Snapshot the latest session ID in logs BEFORE starting OpenCode.
|
|
249
|
-
// This lets us detect whether OpenCode actually resumed vs created a new session.
|
|
250
|
-
const preStartSessionId = snapshotSessionId(this.agentName);
|
|
251
|
-
|
|
252
|
-
console.log(`Creating tmux session: ${sessionName}`);
|
|
253
|
-
|
|
254
|
-
// Include runner env vars (e.g., OPENCODE_MODEL) at session creation
|
|
255
|
-
const created = createSession(
|
|
256
|
-
this.agentName,
|
|
257
|
-
this.agentConfig.command,
|
|
258
|
-
this.agentConfig.workdir,
|
|
259
|
-
this.runnerConfig.env, // Apply model env at process start
|
|
260
|
-
savedSessionId, // Resume OpenCode session if available
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
if (!created) {
|
|
264
|
-
throw new Error("Failed to create tmux session");
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Store pre-start snapshot for fallback detection later
|
|
268
|
-
this._preStartSessionId = preStartSessionId;
|
|
269
|
-
this._attemptedResumeSessionId = savedSessionId;
|
|
270
|
-
} else {
|
|
271
|
-
console.log(`Reconnecting to existing session: ${sessionName}`);
|
|
272
|
-
// Update environment for existing session
|
|
273
|
-
updateSessionEnvironment(this.agentName, this.runnerConfig.env);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Inject environment variables into tmux session
|
|
277
|
-
console.log("Injecting environment variables...");
|
|
278
|
-
updateSessionEnvironment(this.agentName, {
|
|
279
|
-
AGENT_TOKEN: this.token,
|
|
280
|
-
AGENTMESH_AGENT_ID: this.agentId,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Save state including runtime model info
|
|
285
|
-
const sessionName = this.serveMode ? `serve:${this.servePort}` : getSessionName(this.agentName);
|
|
286
|
-
addAgentToState({
|
|
287
|
-
name: this.agentName,
|
|
288
|
-
agentId: this.agentId,
|
|
289
|
-
pid: process.pid,
|
|
290
|
-
tmuxSession: sessionName,
|
|
291
|
-
startedAt: new Date().toISOString(),
|
|
292
|
-
token: this.token,
|
|
293
|
-
workdir: this.agentConfig.workdir,
|
|
294
|
-
assignedProject: this.assignedProject,
|
|
295
|
-
runtimeModel: this.runnerConfig.model,
|
|
296
|
-
runnerType: this.runnerConfig.type,
|
|
297
|
-
sandboxContainer: this.sandbox?.getContainerName(),
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// Start heartbeat with auto-refresh
|
|
301
|
-
console.log("Starting heartbeat...");
|
|
302
|
-
this.heartbeat = new Heartbeat({
|
|
303
|
-
url: this.config.hubUrl,
|
|
304
|
-
token: this.token,
|
|
305
|
-
intervalMs: 30000,
|
|
306
|
-
agentName: this.agentName,
|
|
307
|
-
agentId: this.agentId,
|
|
308
|
-
apiKey: this.config.apiKey,
|
|
309
|
-
workspace: this.config.workspace,
|
|
310
|
-
onError: (error) => {
|
|
311
|
-
console.error("Heartbeat error:", error.message);
|
|
312
|
-
},
|
|
313
|
-
onContextSave: () => {
|
|
314
|
-
// Periodically save context (every 5 heartbeats = ~2.5 minutes)
|
|
315
|
-
this.saveAgentContext();
|
|
316
|
-
},
|
|
317
|
-
contextSaveFrequency: 5,
|
|
318
|
-
onTokenRefresh: (newToken) => {
|
|
319
|
-
this.token = newToken;
|
|
320
|
-
// Update state file
|
|
321
|
-
updateAgentInState(this.agentName, { token: newToken });
|
|
322
|
-
// Update tmux environment
|
|
323
|
-
updateSessionEnvironment(this.agentName, {
|
|
324
|
-
AGENT_TOKEN: newToken,
|
|
325
|
-
AGENTMESH_AGENT_ID: this.agentId!,
|
|
326
|
-
});
|
|
327
|
-
// Reconnect WebSocket with new token
|
|
328
|
-
if (this.ws) {
|
|
329
|
-
this.ws.disconnect();
|
|
330
|
-
const wsUrl = this.config.hubUrl
|
|
331
|
-
.replace("https://", "wss://")
|
|
332
|
-
.replace("http://", "ws://");
|
|
333
|
-
this.ws = new AgentWebSocket({
|
|
334
|
-
url: `${wsUrl}/ws/v1`,
|
|
335
|
-
token: newToken,
|
|
336
|
-
onMessage: (event) => {
|
|
337
|
-
console.log(`[WS] Received event: ${event.type}`);
|
|
338
|
-
handleWebSocketEvent(this.agentName, event);
|
|
339
|
-
},
|
|
340
|
-
onConnect: () => {
|
|
341
|
-
console.log("WebSocket reconnected with new token");
|
|
342
|
-
},
|
|
343
|
-
onDisconnect: () => {
|
|
344
|
-
console.log("WebSocket disconnected");
|
|
345
|
-
},
|
|
346
|
-
onError: (error) => {
|
|
347
|
-
console.error("WebSocket error:", error.message);
|
|
348
|
-
},
|
|
349
|
-
});
|
|
350
|
-
this.ws.connect();
|
|
351
|
-
}
|
|
352
|
-
},
|
|
353
|
-
});
|
|
354
|
-
this.heartbeat.start();
|
|
355
|
-
|
|
356
|
-
// Connect WebSocket
|
|
357
|
-
console.log("Connecting WebSocket...");
|
|
358
|
-
const wsUrl = this.config.hubUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
359
|
-
|
|
360
|
-
this.ws = new AgentWebSocket({
|
|
361
|
-
url: `${wsUrl}/ws/v1`,
|
|
362
|
-
token: this.token,
|
|
363
|
-
onMessage: (event) => {
|
|
364
|
-
console.log(`[WS] Received event: ${event.type}`);
|
|
365
|
-
handleWebSocketEvent(this.agentName, event, {
|
|
366
|
-
hubUrl: this.config.hubUrl,
|
|
367
|
-
token: this.token ?? undefined,
|
|
368
|
-
});
|
|
369
|
-
},
|
|
370
|
-
onConnect: () => {
|
|
371
|
-
console.log("WebSocket connected");
|
|
372
|
-
},
|
|
373
|
-
onDisconnect: () => {
|
|
374
|
-
console.log("WebSocket disconnected");
|
|
375
|
-
},
|
|
376
|
-
onError: (error) => {
|
|
377
|
-
console.error("WebSocket error:", error.message);
|
|
378
|
-
},
|
|
379
|
-
});
|
|
380
|
-
this.ws.connect();
|
|
381
|
-
|
|
382
|
-
// Check inbox and auto-nudge with full handoff details
|
|
383
|
-
console.log("Checking inbox...");
|
|
384
|
-
try {
|
|
385
|
-
const inboxItems = await checkInbox(this.config.hubUrl, this.config.workspace, this.token);
|
|
386
|
-
injectStartupMessage(this.agentName, inboxItems.length, inboxItems);
|
|
387
|
-
} catch (error) {
|
|
388
|
-
console.error("Failed to check inbox:", error);
|
|
389
|
-
injectStartupMessage(this.agentName, 0);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Restore context from previous session
|
|
393
|
-
if (this.shouldRestoreContext && this.agentId) {
|
|
394
|
-
console.log("Checking for previous context...");
|
|
395
|
-
const savedContext = loadContext(this.agentId);
|
|
396
|
-
if (savedContext) {
|
|
397
|
-
if (this._attemptedResumeSessionId && !this.serveMode && !this.sandboxMode) {
|
|
398
|
-
// Native session resume was attempted — verify it worked.
|
|
399
|
-
// Wait for OpenCode to write a NEW session entry to logs.
|
|
400
|
-
// If resume succeeded, it reuses the session (no new entry).
|
|
401
|
-
// If resume failed, OpenCode creates a new session (new entry appears).
|
|
402
|
-
const newSessionId = await waitForNewSessionId(
|
|
403
|
-
this.agentName,
|
|
404
|
-
this._preStartSessionId ?? null,
|
|
405
|
-
15000,
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
if (!newSessionId) {
|
|
409
|
-
// No new session appeared in logs. Could mean:
|
|
410
|
-
// a) Resume succeeded (OpenCode reused session, no new "created" log)
|
|
411
|
-
// b) OpenCode is sitting at splash (session not found, no new session created)
|
|
412
|
-
const health = isSessionHealthy(this.agentName);
|
|
413
|
-
const currentSessionId = getLatestSessionId(this.agentName);
|
|
414
|
-
|
|
415
|
-
if (!health.healthy) {
|
|
416
|
-
// OpenCode died — clear stale session ID to prevent restart loop
|
|
417
|
-
console.log(`[CONTEXT] Fallback: OpenCode not healthy, injecting text summary.`);
|
|
418
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
|
|
419
|
-
saveContext(savedContext);
|
|
420
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
421
|
-
} else if (currentSessionId === this._attemptedResumeSessionId) {
|
|
422
|
-
// The session ID we tried to resume is still the latest — resume worked
|
|
423
|
-
console.log(`[CONTEXT] Resumed OpenCode session ${this._attemptedResumeSessionId}`);
|
|
424
|
-
} else {
|
|
425
|
-
// Pane is alive but no matching session ID in logs — OpenCode is at splash
|
|
426
|
-
console.log(
|
|
427
|
-
`[CONTEXT] Fallback: session not found in OpenCode. Injecting text summary.`,
|
|
428
|
-
);
|
|
429
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
|
|
430
|
-
saveContext(savedContext);
|
|
431
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
432
|
-
}
|
|
433
|
-
} else if (newSessionId === this._attemptedResumeSessionId) {
|
|
434
|
-
// OpenCode logged the same session ID — resume succeeded
|
|
435
|
-
console.log(`[CONTEXT] Resumed OpenCode session ${this._attemptedResumeSessionId}`);
|
|
436
|
-
} else {
|
|
437
|
-
// OpenCode created a different session — resume failed, fallback to text.
|
|
438
|
-
// Update saved context with new session ID to prevent restart loop.
|
|
439
|
-
console.log(
|
|
440
|
-
`[CONTEXT] Fallback: resume failed (expected ${this._attemptedResumeSessionId}, got ${newSessionId}). Injecting text summary.`,
|
|
441
|
-
);
|
|
442
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: newSessionId };
|
|
443
|
-
saveContext(savedContext);
|
|
444
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
445
|
-
}
|
|
446
|
-
} else {
|
|
447
|
-
// No session ID saved or non-tmux mode — use text injection
|
|
448
|
-
console.log(`Restoring context from ${savedContext.savedAt}`);
|
|
449
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
450
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
this.isRunning = true;
|
|
456
|
-
|
|
457
|
-
// Start session health monitoring (every 60 seconds)
|
|
458
|
-
this.startHealthMonitor();
|
|
459
|
-
|
|
460
|
-
console.log(`
|
|
461
|
-
Agent "${this.agentName}" is running.
|
|
462
|
-
|
|
463
|
-
Attach to session:
|
|
464
|
-
agentmesh attach ${this.agentName}
|
|
465
|
-
|
|
466
|
-
Stop agent:
|
|
467
|
-
agentmesh stop ${this.agentName}
|
|
468
|
-
|
|
469
|
-
Nudge agent:
|
|
470
|
-
agentmesh nudge ${this.agentName} "Your message"
|
|
471
|
-
`);
|
|
472
|
-
|
|
473
|
-
// Handle shutdown
|
|
474
|
-
process.on("SIGINT", () => this.stop());
|
|
475
|
-
process.on("SIGTERM", () => this.stop());
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Starts periodic health monitoring for the tmux session
|
|
480
|
-
* Includes auto-restart logic and progress watchdog
|
|
481
|
-
*/
|
|
482
|
-
private startHealthMonitor(): void {
|
|
483
|
-
// Skip health monitoring for serve mode (no tmux session)
|
|
484
|
-
if (this.serveMode) return;
|
|
485
|
-
|
|
486
|
-
const logDir = path.join(os.homedir(), ".agentmesh", "logs");
|
|
487
|
-
if (!fs.existsSync(logDir)) {
|
|
488
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
this.healthCheckInterval = setInterval(async () => {
|
|
492
|
-
if (!this.isRunning) return;
|
|
493
|
-
|
|
494
|
-
// Reset restart count after stable operation
|
|
495
|
-
if (this.lastStableTime && this.restartCount > 0) {
|
|
496
|
-
const stableTime = Date.now() - this.lastStableTime.getTime();
|
|
497
|
-
if (stableTime > RESTART_COUNT_RESET_MS) {
|
|
498
|
-
console.log(`[HEALTH] Agent stable for 30+ minutes, resetting restart count`);
|
|
499
|
-
this.restartCount = 0;
|
|
500
|
-
resetAgentRestartCount(this.agentName);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// For sandbox mode, pass container name so health check looks inside container
|
|
505
|
-
const containerName = this.sandboxMode ? this.sandbox?.getContainerName() : undefined;
|
|
506
|
-
const health = isSessionHealthy(this.agentName, containerName);
|
|
507
|
-
|
|
508
|
-
if (!health.healthy) {
|
|
509
|
-
// Session died - attempt restart
|
|
510
|
-
await this.handleSessionDeath(health.reason || "unknown", logDir);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Session is alive - check progress watchdog
|
|
515
|
-
const progress = checkAgentProgress(this.agentName, containerName);
|
|
516
|
-
|
|
517
|
-
if (progress.status === "permission_blocked" || progress.status === "stuck") {
|
|
518
|
-
await this.handleStuckAgent(progress);
|
|
519
|
-
} else if (progress.status === "active") {
|
|
520
|
-
// Agent is working - reset stuck tracking
|
|
521
|
-
if (this.stuckSince) {
|
|
522
|
-
console.log(`[HEALTH] Agent resumed activity`);
|
|
523
|
-
this.stuckSince = null;
|
|
524
|
-
this.nudgeSentAt = null;
|
|
525
|
-
updateAgentInState(this.agentName, { stuckSince: undefined, status: "running" });
|
|
526
|
-
}
|
|
527
|
-
this.lastStableTime = new Date();
|
|
528
|
-
}
|
|
529
|
-
}, 60000); // Check every 60 seconds
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Handles session death - logs crash and attempts auto-restart
|
|
534
|
-
*/
|
|
535
|
-
private async handleSessionDeath(reason: string, logDir: string): Promise<void> {
|
|
536
|
-
const timestamp = new Date().toISOString();
|
|
537
|
-
const logFile = path.join(logDir, `crash-${this.agentName}.log`);
|
|
538
|
-
|
|
539
|
-
// Capture last session output before it's gone
|
|
540
|
-
let lastOutput = "";
|
|
541
|
-
try {
|
|
542
|
-
lastOutput = captureSessionOutput(this.agentName, 200) || "Unable to capture output";
|
|
543
|
-
} catch {
|
|
544
|
-
lastOutput = "Failed to capture session output";
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const crashLog = `
|
|
548
|
-
================================================================================
|
|
549
|
-
AGENT CRASH DETECTED
|
|
550
|
-
================================================================================
|
|
551
|
-
Timestamp: ${timestamp}
|
|
552
|
-
Agent: ${this.agentName}
|
|
553
|
-
Agent ID: ${this.agentId}
|
|
554
|
-
Reason: ${reason}
|
|
555
|
-
Restart Count: ${this.restartCount}/${MAX_RESTART_ATTEMPTS}
|
|
556
|
-
Sandbox: ${this.sandboxMode ? this.sandbox?.getContainerName() : "none"}
|
|
557
|
-
Workdir: ${this.agentConfig.workdir}
|
|
558
|
-
Model: ${this.runnerConfig.model}
|
|
559
|
-
|
|
560
|
-
--- Last Session Output ---
|
|
561
|
-
${lastOutput}
|
|
562
|
-
================================================================================
|
|
563
|
-
|
|
564
|
-
`;
|
|
565
|
-
|
|
566
|
-
fs.appendFileSync(logFile, crashLog);
|
|
567
|
-
|
|
568
|
-
// Save context (including session ID) before restart attempt
|
|
569
|
-
if (this.agentId) {
|
|
570
|
-
this.saveAgentContext();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Check if we can restart
|
|
574
|
-
if (this.restartCount < MAX_RESTART_ATTEMPTS) {
|
|
575
|
-
this.restartCount++;
|
|
576
|
-
console.error(
|
|
577
|
-
`[CRASH] Session died: ${reason}. Attempting restart (${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`,
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
updateAgentInState(this.agentName, {
|
|
581
|
-
restartCount: this.restartCount,
|
|
582
|
-
lastRestartAt: timestamp,
|
|
583
|
-
status: "running",
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
try {
|
|
587
|
-
await this.restartSession();
|
|
588
|
-
console.log(`[RESTART] Agent restarted successfully`);
|
|
589
|
-
this.lastStableTime = new Date();
|
|
590
|
-
} catch (error) {
|
|
591
|
-
console.error(`[RESTART] Failed to restart: ${(error as Error).message}`);
|
|
592
|
-
}
|
|
593
|
-
} else {
|
|
594
|
-
// Exceeded restart limit - mark as failed
|
|
595
|
-
console.error(
|
|
596
|
-
`[FAILED] Agent exceeded restart limit (${MAX_RESTART_ATTEMPTS}). Manual intervention required.`,
|
|
597
|
-
);
|
|
598
|
-
|
|
599
|
-
// Terminal bell to alert user
|
|
600
|
-
process.stdout.write("\x07");
|
|
601
|
-
|
|
602
|
-
updateAgentInState(this.agentName, {
|
|
603
|
-
status: "failed",
|
|
604
|
-
restartCount: this.restartCount,
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
// Stop monitoring
|
|
608
|
-
this.isRunning = false;
|
|
609
|
-
if (this.healthCheckInterval) {
|
|
610
|
-
clearInterval(this.healthCheckInterval);
|
|
611
|
-
this.healthCheckInterval = null;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Handles stuck agent - sends nudge first, then restarts if still stuck
|
|
618
|
-
*/
|
|
619
|
-
private async handleStuckAgent(progress: {
|
|
620
|
-
status: string;
|
|
621
|
-
blockedOn?: string;
|
|
622
|
-
details?: string;
|
|
623
|
-
}): Promise<void> {
|
|
624
|
-
const now = new Date();
|
|
625
|
-
|
|
626
|
-
if (!this.stuckSince) {
|
|
627
|
-
// First detection of stuck state
|
|
628
|
-
this.stuckSince = now;
|
|
629
|
-
console.log(
|
|
630
|
-
`[HEALTH] Agent appears stuck: ${progress.details || progress.blockedOn || "no activity"}`,
|
|
631
|
-
);
|
|
632
|
-
|
|
633
|
-
updateAgentInState(this.agentName, {
|
|
634
|
-
stuckSince: now.toISOString(),
|
|
635
|
-
status: "stuck",
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// If we haven't sent a nudge yet, send one
|
|
640
|
-
if (!this.nudgeSentAt) {
|
|
641
|
-
console.log(`[HEALTH] Sending nudge to unstick agent...`);
|
|
642
|
-
|
|
643
|
-
const nudgeMessage =
|
|
644
|
-
progress.status === "permission_blocked"
|
|
645
|
-
? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
|
|
646
|
-
: "Please continue with your current task.";
|
|
647
|
-
|
|
648
|
-
const sent = sendNudge(this.agentName, nudgeMessage);
|
|
649
|
-
if (sent) {
|
|
650
|
-
this.nudgeSentAt = now;
|
|
651
|
-
console.log(`[HEALTH] Nudge sent successfully`);
|
|
652
|
-
} else {
|
|
653
|
-
console.log(`[HEALTH] Failed to send nudge`);
|
|
654
|
-
}
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Check if enough time has passed since nudge
|
|
659
|
-
const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
|
|
660
|
-
if (timeSinceNudge < NUDGE_WAIT_MS) {
|
|
661
|
-
// Still waiting for agent to respond to nudge
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Agent still stuck after nudge - trigger restart
|
|
666
|
-
console.log(`[HEALTH] Agent still stuck after nudge, triggering restart...`);
|
|
667
|
-
this.stuckSince = null;
|
|
668
|
-
this.nudgeSentAt = null;
|
|
669
|
-
|
|
670
|
-
await this.handleSessionDeath(
|
|
671
|
-
"stuck_after_nudge",
|
|
672
|
-
path.join(os.homedir(), ".agentmesh", "logs"),
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Restarts the agent session (sandbox or non-sandbox)
|
|
678
|
-
*/
|
|
679
|
-
private async restartSession(): Promise<void> {
|
|
680
|
-
// Destroy existing session
|
|
681
|
-
destroySession(this.agentName);
|
|
682
|
-
|
|
683
|
-
if (this.sandboxMode && this.sandbox) {
|
|
684
|
-
// Restart sandbox container
|
|
685
|
-
const newContainerId = await this.sandbox.restart();
|
|
686
|
-
console.log(`[RESTART] New container: ${newContainerId.substring(0, 12)}`);
|
|
687
|
-
|
|
688
|
-
// Recreate tmux session for sandbox
|
|
689
|
-
const containerName = this.sandbox.getContainerName();
|
|
690
|
-
const sessionName = getSessionName(this.agentName);
|
|
691
|
-
|
|
692
|
-
// Build environment args for docker exec
|
|
693
|
-
const envArgs: string[] = [];
|
|
694
|
-
const allEnv = {
|
|
695
|
-
...this.runnerConfig.env,
|
|
696
|
-
AGENT_TOKEN: this.token!,
|
|
697
|
-
AGENTMESH_AGENT_ID: this.agentId!,
|
|
698
|
-
};
|
|
699
|
-
for (const [key, value] of Object.entries(allEnv)) {
|
|
700
|
-
if (value !== undefined && value !== "") {
|
|
701
|
-
envArgs.push(`-e "${key}=${value}"`);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
const envString = envArgs.join(" ");
|
|
705
|
-
const modelArg = this.runnerConfig.env?.OPENCODE_MODEL
|
|
706
|
-
? ` --model ${this.runnerConfig.env.OPENCODE_MODEL}`
|
|
707
|
-
: "";
|
|
708
|
-
const dockerExecCommand = `docker exec -it ${envString} ${containerName} opencode${modelArg}`;
|
|
709
|
-
|
|
710
|
-
const created = createSession(this.agentName, dockerExecCommand, undefined, undefined);
|
|
711
|
-
if (!created) {
|
|
712
|
-
throw new Error("Failed to create tmux session for restarted sandbox");
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Update state with new container name
|
|
716
|
-
updateAgentInState(this.agentName, {
|
|
717
|
-
sandboxContainer: containerName,
|
|
718
|
-
});
|
|
719
|
-
} else {
|
|
720
|
-
// Non-sandbox restart — load saved session ID for native resume
|
|
721
|
-
let savedSessionId: string | undefined;
|
|
722
|
-
let savedContext = null;
|
|
723
|
-
if (this.agentId) {
|
|
724
|
-
savedContext = loadContext(this.agentId);
|
|
725
|
-
savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
|
|
726
|
-
if (savedSessionId) {
|
|
727
|
-
console.log(`[RESTART] Attempting to resume OpenCode session: ${savedSessionId}`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const preRestartSessionId = snapshotSessionId(this.agentName);
|
|
732
|
-
|
|
733
|
-
const created = createSession(
|
|
734
|
-
this.agentName,
|
|
735
|
-
this.agentConfig.command,
|
|
736
|
-
this.agentConfig.workdir,
|
|
737
|
-
this.runnerConfig.env,
|
|
738
|
-
savedSessionId,
|
|
739
|
-
);
|
|
740
|
-
|
|
741
|
-
if (!created) {
|
|
742
|
-
throw new Error("Failed to create tmux session");
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Re-inject environment
|
|
746
|
-
updateSessionEnvironment(this.agentName, {
|
|
747
|
-
AGENT_TOKEN: this.token!,
|
|
748
|
-
AGENTMESH_AGENT_ID: this.agentId!,
|
|
749
|
-
...this.runnerConfig.env,
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
// Verify native resume and fallback if needed
|
|
753
|
-
if (savedSessionId && savedContext) {
|
|
754
|
-
const newSessionId = await waitForNewSessionId(this.agentName, preRestartSessionId, 15000);
|
|
755
|
-
|
|
756
|
-
if (!newSessionId) {
|
|
757
|
-
const health = isSessionHealthy(this.agentName);
|
|
758
|
-
const currentSessionId = getLatestSessionId(this.agentName);
|
|
759
|
-
|
|
760
|
-
if (!health.healthy) {
|
|
761
|
-
console.log(`[RESTART] Fallback: OpenCode not healthy, injecting text summary.`);
|
|
762
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
|
|
763
|
-
saveContext(savedContext);
|
|
764
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
765
|
-
} else if (currentSessionId === savedSessionId) {
|
|
766
|
-
console.log(`[RESTART] Resumed OpenCode session ${savedSessionId}`);
|
|
767
|
-
} else {
|
|
768
|
-
console.log(`[RESTART] Fallback: session not found. Injecting text summary.`);
|
|
769
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
|
|
770
|
-
saveContext(savedContext);
|
|
771
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
772
|
-
}
|
|
773
|
-
} else if (newSessionId === savedSessionId) {
|
|
774
|
-
console.log(`[RESTART] Resumed OpenCode session ${savedSessionId}`);
|
|
775
|
-
} else {
|
|
776
|
-
console.log(
|
|
777
|
-
`[RESTART] Fallback: resume failed (got ${newSessionId}). Injecting text summary.`,
|
|
778
|
-
);
|
|
779
|
-
savedContext.custom = { ...savedContext.custom, opencodeSessionId: newSessionId };
|
|
780
|
-
saveContext(savedContext);
|
|
781
|
-
injectRestoredContext(this.agentName, savedContext);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Wait for session to be ready
|
|
787
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
async stop(): Promise<void> {
|
|
791
|
-
console.log(`\nStopping agent: ${this.agentName}`);
|
|
792
|
-
|
|
793
|
-
this.isRunning = false;
|
|
794
|
-
|
|
795
|
-
// Stop health monitor
|
|
796
|
-
if (this.healthCheckInterval) {
|
|
797
|
-
clearInterval(this.healthCheckInterval);
|
|
798
|
-
this.healthCheckInterval = null;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Save context before stopping
|
|
802
|
-
if (this.agentId) {
|
|
803
|
-
console.log("Saving agent context...");
|
|
804
|
-
this.saveAgentContext();
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Stop heartbeat
|
|
808
|
-
if (this.heartbeat) {
|
|
809
|
-
this.heartbeat.stop();
|
|
810
|
-
this.heartbeat = null;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Disconnect WebSocket
|
|
814
|
-
if (this.ws) {
|
|
815
|
-
this.ws.disconnect();
|
|
816
|
-
this.ws = null;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// Stop sandbox, serve process, or destroy tmux session
|
|
820
|
-
if (this.sandboxMode && this.sandbox) {
|
|
821
|
-
console.log("Stopping sandbox...");
|
|
822
|
-
// In sandbox mode, we have both a tmux session (on host) and a Docker container
|
|
823
|
-
// Destroy tmux session first (this stops docker exec)
|
|
824
|
-
destroySession(this.agentName);
|
|
825
|
-
// Then destroy the container
|
|
826
|
-
await this.sandbox.destroy();
|
|
827
|
-
this.sandbox = null;
|
|
828
|
-
} else if (this.serveMode && this.serveProcess) {
|
|
829
|
-
console.log("Stopping opencode serve...");
|
|
830
|
-
this.serveProcess.kill("SIGTERM");
|
|
831
|
-
this.serveProcess = null;
|
|
832
|
-
} else {
|
|
833
|
-
destroySession(this.agentName);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// Update state to mark as stopped but preserve agentId for next restart
|
|
837
|
-
updateAgentInState(this.agentName, {
|
|
838
|
-
pid: 0,
|
|
839
|
-
tmuxSession: "",
|
|
840
|
-
startedAt: "",
|
|
841
|
-
token: undefined,
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
console.log("Agent stopped.");
|
|
845
|
-
process.exit(0);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Starts opencode serve mode (for Integration Service)
|
|
850
|
-
* Replaces tmux with a direct HTTP server
|
|
851
|
-
*/
|
|
852
|
-
private async startServeMode(): Promise<void> {
|
|
853
|
-
console.log(`Starting opencode serve mode on port ${this.servePort}...`);
|
|
854
|
-
|
|
855
|
-
const workdir = this.agentConfig.workdir || process.cwd();
|
|
856
|
-
|
|
857
|
-
// Isolate OpenCode's SQLite database per agent to prevent WAL corruption.
|
|
858
|
-
// See docs/RCA-OPENCODE-SQLITE-CORRUPTION.md for details.
|
|
859
|
-
const agentDataDir = path.join(os.homedir(), ".agentmesh", "opencode-data", this.agentName);
|
|
860
|
-
const agentOpencodeDir = path.join(agentDataDir, "opencode");
|
|
861
|
-
if (!fs.existsSync(agentOpencodeDir)) {
|
|
862
|
-
fs.mkdirSync(agentOpencodeDir, { recursive: true });
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// Copy auth.json from default OpenCode data dir so agents inherit API keys.
|
|
866
|
-
// Strips xAI provider to prevent OpenCode from defaulting to non-Anthropic models.
|
|
867
|
-
const agentAuthPath = path.join(agentOpencodeDir, "auth.json");
|
|
868
|
-
const sourceAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
|
|
869
|
-
if (!fs.existsSync(agentAuthPath) && fs.existsSync(sourceAuthPath)) {
|
|
870
|
-
try {
|
|
871
|
-
const auth = JSON.parse(fs.readFileSync(sourceAuthPath, "utf-8"));
|
|
872
|
-
delete auth.xai;
|
|
873
|
-
fs.writeFileSync(agentAuthPath, JSON.stringify(auth, null, 2));
|
|
874
|
-
} catch {
|
|
875
|
-
// Non-fatal — agent will just need manual auth
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Build environment for opencode serve
|
|
880
|
-
const env: Record<string, string> = {
|
|
881
|
-
...process.env,
|
|
882
|
-
...this.runnerConfig.env,
|
|
883
|
-
AGENT_TOKEN: this.token!,
|
|
884
|
-
AGENTMESH_AGENT_ID: this.agentId!,
|
|
885
|
-
XDG_DATA_HOME: agentDataDir,
|
|
886
|
-
} as Record<string, string>;
|
|
887
|
-
|
|
888
|
-
// Spawn opencode serve as a child process
|
|
889
|
-
this.serveProcess = spawn(
|
|
890
|
-
"opencode",
|
|
891
|
-
["serve", "--port", String(this.servePort), "--hostname", "0.0.0.0"],
|
|
892
|
-
{
|
|
893
|
-
cwd: workdir,
|
|
894
|
-
env,
|
|
895
|
-
stdio: ["ignore", "inherit", "inherit"],
|
|
896
|
-
},
|
|
897
|
-
);
|
|
898
|
-
|
|
899
|
-
// Handle process exit
|
|
900
|
-
this.serveProcess.on("exit", (code, signal) => {
|
|
901
|
-
console.error(`opencode serve exited with code ${code}, signal ${signal}`);
|
|
902
|
-
if (this.isRunning) {
|
|
903
|
-
console.log("Restarting opencode serve in 5 seconds...");
|
|
904
|
-
setTimeout(() => {
|
|
905
|
-
if (this.isRunning) {
|
|
906
|
-
this.startServeMode().catch(console.error);
|
|
907
|
-
}
|
|
908
|
-
}, 5000);
|
|
909
|
-
}
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
this.serveProcess.on("error", (error) => {
|
|
913
|
-
console.error("Failed to start opencode serve:", error);
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
// Wait a moment for the server to start
|
|
917
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
918
|
-
|
|
919
|
-
console.log(`opencode serve started on http://0.0.0.0:${this.servePort}`);
|
|
920
|
-
|
|
921
|
-
// Store saved session ID for integration service reuse
|
|
922
|
-
if (this.shouldRestoreContext && this.agentId) {
|
|
923
|
-
const savedContext = loadContext(this.agentId);
|
|
924
|
-
const savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
|
|
925
|
-
if (savedSessionId) {
|
|
926
|
-
console.log(`[SERVE] Saved OpenCode session available for reuse: ${savedSessionId}`);
|
|
927
|
-
updateAgentInState(this.agentName, { opencodeSessionId: savedSessionId });
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Starts agent in Docker sandbox mode
|
|
934
|
-
* Provides filesystem isolation with only workspace mounted
|
|
935
|
-
*
|
|
936
|
-
* Strategy: Start Docker container with tail -f /dev/null, then create
|
|
937
|
-
* a tmux session on the HOST that runs `docker exec -it <container> opencode`.
|
|
938
|
-
* This way tmux provides the TTY that docker exec needs.
|
|
939
|
-
*/
|
|
940
|
-
private async startSandboxMode(): Promise<void> {
|
|
941
|
-
console.log("Starting in Docker sandbox mode...");
|
|
942
|
-
|
|
943
|
-
// Check Docker availability
|
|
944
|
-
if (!DockerSandbox.checkDockerAvailable()) {
|
|
945
|
-
throw new Error(
|
|
946
|
-
"Docker is not available. Install Docker or use --sandbox host to run on host.",
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const workdir = this.agentConfig.workdir || process.cwd();
|
|
951
|
-
|
|
952
|
-
// Check for existing sandbox container
|
|
953
|
-
const existingContainer = DockerSandbox.findExisting(this.agentName);
|
|
954
|
-
if (existingContainer) {
|
|
955
|
-
console.log(`Found existing sandbox container: ${existingContainer}`);
|
|
956
|
-
console.log(`Stop it with: agentmesh stop ${this.agentName}`);
|
|
957
|
-
throw new Error("Sandbox container already exists");
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Build additional mounts for credentials and config
|
|
961
|
-
// The entrypoint script copies these from /tmp/ to the correct locations
|
|
962
|
-
const additionalMounts: string[] = [];
|
|
963
|
-
|
|
964
|
-
// Mount git credentials
|
|
965
|
-
const gitCredentialsPath = path.join(os.homedir(), ".git-credentials");
|
|
966
|
-
if (fs.existsSync(gitCredentialsPath)) {
|
|
967
|
-
additionalMounts.push(`${gitCredentialsPath}:/tmp/.git-credentials-host:ro`);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// Mount OpenCode auth.json for API provider tokens (Anthropic, OpenAI, etc.)
|
|
971
|
-
const opencodeAuthPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
|
|
972
|
-
if (fs.existsSync(opencodeAuthPath)) {
|
|
973
|
-
additionalMounts.push(`${opencodeAuthPath}:/tmp/.opencode-auth-host:ro`);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// Mount AgentMesh config for hub URL, API key, workspace
|
|
977
|
-
const agentmeshConfigPath = path.join(os.homedir(), ".agentmesh", "config.json");
|
|
978
|
-
if (fs.existsSync(agentmeshConfigPath)) {
|
|
979
|
-
additionalMounts.push(`${agentmeshConfigPath}:/tmp/.agentmesh-config-host:ro`);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Create and mount permissive OpenCode config for sandbox
|
|
983
|
-
// This allows all permissions since the container is already sandboxed
|
|
984
|
-
this.ensureSandboxOpencodeConfig();
|
|
985
|
-
additionalMounts.push(`${SANDBOX_OPENCODE_CONFIG_PATH}:/workspace/opencode.json:ro`);
|
|
986
|
-
|
|
987
|
-
// Pass GitHub token as environment variable for git operations
|
|
988
|
-
const gitCredentials = fs.existsSync(gitCredentialsPath)
|
|
989
|
-
? fs.readFileSync(gitCredentialsPath, "utf-8").trim()
|
|
990
|
-
: "";
|
|
991
|
-
const gitHubToken = gitCredentials.match(/github_pat_[^\s@]+/)?.[0] || "";
|
|
992
|
-
|
|
993
|
-
// Build the command to run inside the container
|
|
994
|
-
// The agentmesh CLI inside the container will create tmux + opencode
|
|
995
|
-
const model =
|
|
996
|
-
this.runnerConfig.env?.OPENCODE_MODEL || this.runnerConfig.model || "claude-sonnet-4";
|
|
997
|
-
const containerCommand = [
|
|
998
|
-
"agentmesh",
|
|
999
|
-
"start",
|
|
1000
|
-
"--name",
|
|
1001
|
-
this.agentName,
|
|
1002
|
-
"--model",
|
|
1003
|
-
model,
|
|
1004
|
-
"--foreground",
|
|
1005
|
-
];
|
|
1006
|
-
|
|
1007
|
-
// Create sandbox configuration
|
|
1008
|
-
// Isolate OpenCode's SQLite database per agent to prevent WAL corruption.
|
|
1009
|
-
const hostDataDir = path.join(os.homedir(), ".agentmesh", "opencode-data", this.agentName);
|
|
1010
|
-
if (!fs.existsSync(hostDataDir)) {
|
|
1011
|
-
fs.mkdirSync(hostDataDir, { recursive: true });
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
this.sandbox = new DockerSandbox({
|
|
1015
|
-
agentName: this.agentName,
|
|
1016
|
-
image: this.sandboxImage,
|
|
1017
|
-
workspacePath: workdir,
|
|
1018
|
-
cpuLimit: this.sandboxCpu,
|
|
1019
|
-
memoryLimit: this.sandboxMemory,
|
|
1020
|
-
env: {
|
|
1021
|
-
...this.runnerConfig.env,
|
|
1022
|
-
AGENT_TOKEN: this.token!,
|
|
1023
|
-
AGENTMESH_AGENT_ID: this.agentId!,
|
|
1024
|
-
AGENT_NAME: this.agentName,
|
|
1025
|
-
// XDG_DATA_HOME set by entrypoint based on AGENT_NAME
|
|
1026
|
-
// Git credentials for pushing to GitHub
|
|
1027
|
-
...(gitHubToken && { GH_TOKEN: gitHubToken, GITHUB_TOKEN: gitHubToken }),
|
|
1028
|
-
},
|
|
1029
|
-
serveMode: this.serveMode,
|
|
1030
|
-
servePort: this.servePort,
|
|
1031
|
-
networkMode: "bridge",
|
|
1032
|
-
additionalMounts: [
|
|
1033
|
-
...additionalMounts,
|
|
1034
|
-
`${hostDataDir}:/home/node/.agentmesh/opencode-data/${this.agentName}:rw`,
|
|
1035
|
-
],
|
|
1036
|
-
command: this.serveMode ? undefined : containerCommand,
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
// Validate mount policy (will throw if denied)
|
|
1040
|
-
this.sandbox.validateMountPolicy();
|
|
1041
|
-
|
|
1042
|
-
// Pull image if needed
|
|
1043
|
-
await this.sandbox.pullImage();
|
|
1044
|
-
|
|
1045
|
-
// Start container with agentmesh running inside
|
|
1046
|
-
// The entrypoint script sets up credentials before agentmesh starts
|
|
1047
|
-
await this.sandbox.start();
|
|
1048
|
-
|
|
1049
|
-
const containerName = this.sandbox.getContainerName();
|
|
1050
|
-
|
|
1051
|
-
console.log(`
|
|
1052
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1053
|
-
SANDBOX MODE ACTIVE
|
|
1054
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1055
|
-
|
|
1056
|
-
Container: ${containerName}
|
|
1057
|
-
Image: ${this.sandboxImage}
|
|
1058
|
-
Workspace: ${workdir} -> /workspace
|
|
1059
|
-
CPU: ${this.sandboxCpu} core(s)
|
|
1060
|
-
Memory: ${this.sandboxMemory}
|
|
1061
|
-
Model: ${model}
|
|
1062
|
-
|
|
1063
|
-
The agent daemon is running INSIDE the Docker container.
|
|
1064
|
-
tmux session and OpenCode are managed inside the container.
|
|
1065
|
-
|
|
1066
|
-
Attach: agentmesh attach ${this.agentName}
|
|
1067
|
-
Nudge: agentmesh nudge ${this.agentName} "message"
|
|
1068
|
-
Stop: agentmesh stop ${this.agentName}
|
|
1069
|
-
Logs: docker logs ${containerName}
|
|
1070
|
-
|
|
1071
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1072
|
-
`);
|
|
1073
|
-
|
|
1074
|
-
// No host tmux session needed - the container runs agentmesh which creates its own tmux
|
|
1075
|
-
// Heartbeats are sent by the daemon running inside the container
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
/**
|
|
1079
|
-
* Saves the current agent context to disk
|
|
1080
|
-
*/
|
|
1081
|
-
private saveAgentContext(): void {
|
|
1082
|
-
if (!this.agentId) return;
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
// Load existing context or create new
|
|
1086
|
-
const context = loadOrCreateContext(this.agentId, this.agentName);
|
|
1087
|
-
|
|
1088
|
-
// Capture current session state
|
|
1089
|
-
const sessionContext = captureSessionContext(this.agentName);
|
|
1090
|
-
if (sessionContext) {
|
|
1091
|
-
context.workingState = {
|
|
1092
|
-
...context.workingState,
|
|
1093
|
-
workdir: sessionContext.workdir,
|
|
1094
|
-
gitBranch: sessionContext.gitBranch,
|
|
1095
|
-
gitStatus: sessionContext.gitStatus,
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// Capture OpenCode session ID for native resume on restart
|
|
1100
|
-
const sessionId = getLatestSessionId(this.agentName);
|
|
1101
|
-
if (sessionId) {
|
|
1102
|
-
context.custom = { ...context.custom, opencodeSessionId: sessionId };
|
|
1103
|
-
console.log(`[CONTEXT] Captured OpenCode session ID: ${sessionId}`);
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
// Save updated context
|
|
1107
|
-
saveContext(context);
|
|
1108
|
-
console.log(`Context saved for agent ${this.agentName}`);
|
|
1109
|
-
} catch (error) {
|
|
1110
|
-
console.error("Failed to save agent context:", error);
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
/**
|
|
1115
|
-
* Fetches assignments from HQ and validates workdir setup
|
|
1116
|
-
* Uses project.workdir from HQ as source of truth, falls back to helpful instructions
|
|
1117
|
-
*/
|
|
1118
|
-
private async checkAssignments(): Promise<void> {
|
|
1119
|
-
if (!this.token) return;
|
|
1120
|
-
|
|
1121
|
-
try {
|
|
1122
|
-
console.log("Fetching project assignments from HQ...");
|
|
1123
|
-
const assignments = await fetchAssignments(this.config.hubUrl, this.token);
|
|
1124
|
-
|
|
1125
|
-
if (assignments.length === 0) {
|
|
1126
|
-
console.log("No project assignments found.");
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
console.log(`Found ${assignments.length} assignment(s):`);
|
|
1131
|
-
for (const assignment of assignments) {
|
|
1132
|
-
const repoInfo = assignment.repo ? ` -> ${assignment.repo.full_name}` : "";
|
|
1133
|
-
const workdirInfo = assignment.project.workdir ? ` [${assignment.project.workdir}]` : "";
|
|
1134
|
-
console.log(` - ${assignment.project.name} (${assignment.role})${repoInfo}${workdirInfo}`);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
this.assignedProject = assignments[0]?.project.name;
|
|
1138
|
-
|
|
1139
|
-
// If no CLI workdir specified, try to use project.workdir from HQ
|
|
1140
|
-
if (!this.agentConfig.workdir) {
|
|
1141
|
-
const assignmentWithWorkdir = assignments.find((a) => a.project.workdir);
|
|
1142
|
-
if (assignmentWithWorkdir?.project.workdir) {
|
|
1143
|
-
console.log(
|
|
1144
|
-
`Using workdir from project settings: ${assignmentWithWorkdir.project.workdir}`,
|
|
1145
|
-
);
|
|
1146
|
-
this.agentConfig.workdir = assignmentWithWorkdir.project.workdir;
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// No project.workdir set, check if we have a repo assignment
|
|
1151
|
-
const repoAssignment = assignments.find((a) => a.repo !== null);
|
|
1152
|
-
if (repoAssignment) {
|
|
1153
|
-
const repo = repoAssignment.repo!;
|
|
1154
|
-
const expandedPath = path.join(
|
|
1155
|
-
os.homedir(),
|
|
1156
|
-
".agentmesh",
|
|
1157
|
-
"workspaces",
|
|
1158
|
-
this.config.workspace,
|
|
1159
|
-
repoAssignment.project.code.toLowerCase(),
|
|
1160
|
-
this.agentName,
|
|
1161
|
-
);
|
|
1162
|
-
const suggestedPath = `~/.agentmesh/workspaces/${this.config.workspace}/${repoAssignment.project.code.toLowerCase()}/${this.agentName}`;
|
|
1163
|
-
|
|
1164
|
-
// If --auto-setup is enabled, automatically clone the repo
|
|
1165
|
-
if (this.autoSetup) {
|
|
1166
|
-
this.agentConfig.workdir = this.setupWorkspace(
|
|
1167
|
-
expandedPath,
|
|
1168
|
-
repo.url,
|
|
1169
|
-
repo.default_branch,
|
|
1170
|
-
repoAssignment.project.name,
|
|
1171
|
-
);
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
console.error(`
|
|
1176
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1177
|
-
⚠️ WORKDIR REQUIRED
|
|
1178
|
-
|
|
1179
|
-
You have a project assignment with a repository, but no workdir is configured.
|
|
1180
|
-
|
|
1181
|
-
Project: ${repoAssignment.project.name}
|
|
1182
|
-
Repo: ${repo.full_name}
|
|
1183
|
-
Branch: ${repo.default_branch}
|
|
1184
|
-
|
|
1185
|
-
Option 1: Set workdir in project settings (recommended)
|
|
1186
|
-
- Go to AgentMesh HQ → Projects → ${repoAssignment.project.name} → Settings
|
|
1187
|
-
- Set the workdir field to the local path
|
|
1188
|
-
|
|
1189
|
-
Option 2: Set up workspace manually and pass --workdir:
|
|
1190
|
-
|
|
1191
|
-
mkdir -p ${suggestedPath}
|
|
1192
|
-
git clone ${repo.url} ${suggestedPath}
|
|
1193
|
-
cd ${suggestedPath} && git checkout ${repo.default_branch}
|
|
1194
|
-
|
|
1195
|
-
Then start the agent with:
|
|
1196
|
-
|
|
1197
|
-
agentmesh start -n ${this.agentName} --workdir ${suggestedPath}
|
|
1198
|
-
|
|
1199
|
-
Option 3: Use --auto-setup to automatically clone the repository:
|
|
1200
|
-
|
|
1201
|
-
agentmesh start -n ${this.agentName} --auto-setup
|
|
1202
|
-
|
|
1203
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1204
|
-
`);
|
|
1205
|
-
// No session to clean up - we haven't created it yet
|
|
1206
|
-
process.exit(1);
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
} catch (error) {
|
|
1210
|
-
// Non-fatal: log and continue (HQ might not have assignments feature yet)
|
|
1211
|
-
console.log("Could not fetch assignments:", (error as Error).message);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
/**
|
|
1216
|
-
* Sets up workspace by cloning repository or using existing clone
|
|
1217
|
-
* Returns the absolute path to the workspace
|
|
1218
|
-
*/
|
|
1219
|
-
private setupWorkspace(
|
|
1220
|
-
workspacePath: string,
|
|
1221
|
-
repoUrl: string,
|
|
1222
|
-
defaultBranch: string,
|
|
1223
|
-
projectName: string,
|
|
1224
|
-
): string {
|
|
1225
|
-
console.log(
|
|
1226
|
-
`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
|
|
1227
|
-
);
|
|
1228
|
-
console.log(`🔧 AUTO-SETUP: Setting up workspace for ${projectName}`);
|
|
1229
|
-
console.log(
|
|
1230
|
-
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`,
|
|
1231
|
-
);
|
|
1232
|
-
|
|
1233
|
-
// Check if directory already exists and is a git repo
|
|
1234
|
-
const gitDir = path.join(workspacePath, ".git");
|
|
1235
|
-
if (fs.existsSync(gitDir)) {
|
|
1236
|
-
console.log(`✓ Workspace already exists: ${workspacePath}`);
|
|
1237
|
-
console.log(` Updating from remote...`);
|
|
1238
|
-
|
|
1239
|
-
try {
|
|
1240
|
-
// Fetch and checkout the branch
|
|
1241
|
-
execSync(`git fetch origin`, { cwd: workspacePath, stdio: "inherit" });
|
|
1242
|
-
execSync(`git checkout ${defaultBranch}`, { cwd: workspacePath, stdio: "inherit" });
|
|
1243
|
-
execSync(`git pull origin ${defaultBranch}`, { cwd: workspacePath, stdio: "inherit" });
|
|
1244
|
-
console.log(`✓ Workspace updated successfully\n`);
|
|
1245
|
-
} catch (error) {
|
|
1246
|
-
console.warn(`⚠ Could not update workspace: ${(error as Error).message}`);
|
|
1247
|
-
console.log(` Continuing with existing state...\n`);
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
return workspacePath;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Create parent directories
|
|
1254
|
-
const parentDir = path.dirname(workspacePath);
|
|
1255
|
-
if (!fs.existsSync(parentDir)) {
|
|
1256
|
-
console.log(`Creating directory: ${parentDir}`);
|
|
1257
|
-
fs.mkdirSync(parentDir, { recursive: true });
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// Clone the repository
|
|
1261
|
-
console.log(`Cloning repository...`);
|
|
1262
|
-
console.log(` URL: ${repoUrl}`);
|
|
1263
|
-
console.log(` Path: ${workspacePath}`);
|
|
1264
|
-
console.log(` Branch: ${defaultBranch}\n`);
|
|
1265
|
-
|
|
1266
|
-
try {
|
|
1267
|
-
execSync(`git clone --branch ${defaultBranch} "${repoUrl}" "${workspacePath}"`, {
|
|
1268
|
-
stdio: "inherit",
|
|
1269
|
-
});
|
|
1270
|
-
console.log(`\n✓ Repository cloned successfully`);
|
|
1271
|
-
} catch (error) {
|
|
1272
|
-
console.error(`\n✗ Failed to clone repository: ${(error as Error).message}`);
|
|
1273
|
-
console.error(`\nMake sure you have access to the repository and SSH keys are configured.`);
|
|
1274
|
-
// No session to clean up - we haven't created it yet
|
|
1275
|
-
process.exit(1);
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
console.log(`✓ Workspace ready: ${workspacePath}\n`);
|
|
1279
|
-
return workspacePath;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
/**
|
|
1283
|
-
* Ensures the sandbox OpenCode config exists
|
|
1284
|
-
* Creates ~/.agentmesh/opencode-sandbox.json with permissive permissions and model
|
|
1285
|
-
*/
|
|
1286
|
-
private ensureSandboxOpencodeConfig(): void {
|
|
1287
|
-
const configDir = path.dirname(SANDBOX_OPENCODE_CONFIG_PATH);
|
|
1288
|
-
|
|
1289
|
-
if (!fs.existsSync(configDir)) {
|
|
1290
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// Build config with model if available
|
|
1294
|
-
const config: Record<string, unknown> = {
|
|
1295
|
-
...SANDBOX_OPENCODE_CONFIG,
|
|
1296
|
-
};
|
|
1297
|
-
|
|
1298
|
-
// Include model from runner config
|
|
1299
|
-
const model = this.runnerConfig.env?.OPENCODE_MODEL;
|
|
1300
|
-
if (model) {
|
|
1301
|
-
config.model = model;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
// Always write to ensure model is up to date
|
|
1305
|
-
fs.writeFileSync(SANDBOX_OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1306
|
-
console.log(`Updated sandbox OpenCode config: ${SANDBOX_OPENCODE_CONFIG_PATH}`);
|
|
1307
|
-
}
|
|
1308
|
-
}
|