@agentmeshhq/agent 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/__tests__/runner.test.d.ts +1 -0
  2. package/dist/__tests__/runner.test.js +87 -0
  3. package/dist/__tests__/runner.test.js.map +1 -0
  4. package/dist/cli/index.js +5 -3
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/restart.d.ts +3 -1
  7. package/dist/cli/restart.js +11 -3
  8. package/dist/cli/restart.js.map +1 -1
  9. package/dist/cli/start.d.ts +1 -0
  10. package/dist/cli/start.js +3 -0
  11. package/dist/cli/start.js.map +1 -1
  12. package/dist/cli/status.js +7 -0
  13. package/dist/cli/status.js.map +1 -1
  14. package/dist/cli/whoami.js +25 -0
  15. package/dist/cli/whoami.js.map +1 -1
  16. package/dist/config/schema.d.ts +5 -0
  17. package/dist/core/daemon.d.ts +14 -0
  18. package/dist/core/daemon.js +173 -17
  19. package/dist/core/daemon.js.map +1 -1
  20. package/dist/core/registry.d.ts +26 -0
  21. package/dist/core/registry.js +18 -0
  22. package/dist/core/registry.js.map +1 -1
  23. package/dist/core/runner.d.ts +49 -0
  24. package/dist/core/runner.js +148 -0
  25. package/dist/core/runner.js.map +1 -0
  26. package/dist/core/tmux.d.ts +3 -0
  27. package/dist/core/tmux.js +23 -7
  28. package/dist/core/tmux.js.map +1 -1
  29. package/package.json +11 -11
  30. package/src/__tests__/runner.test.ts +105 -0
  31. package/src/cli/index.ts +5 -3
  32. package/src/cli/restart.ts +12 -3
  33. package/src/cli/start.ts +3 -0
  34. package/src/cli/status.ts +8 -0
  35. package/src/cli/whoami.ts +30 -1
  36. package/src/config/schema.ts +6 -0
  37. package/src/core/daemon.ts +223 -20
  38. package/src/core/registry.ts +57 -16
  39. package/src/core/runner.ts +200 -0
  40. package/src/core/tmux.ts +28 -9
  41. package/LICENSE +0 -21
@@ -1,3 +1,7 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
1
5
  import {
2
6
  addAgentToState,
3
7
  getAgentState,
@@ -9,7 +13,13 @@ import type { AgentConfig, Config } from "../config/schema.js";
9
13
  import { loadContext, loadOrCreateContext, saveContext } from "../context/index.js";
10
14
  import { Heartbeat } from "./heartbeat.js";
11
15
  import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } from "./injector.js";
12
- import { checkInbox, registerAgent } from "./registry.js";
16
+ import { checkInbox, fetchAssignments, registerAgent } from "./registry.js";
17
+ import {
18
+ buildRunnerConfig,
19
+ detectRunner,
20
+ getRunnerDisplayName,
21
+ type RunnerConfig,
22
+ } from "./runner.js";
13
23
  import {
14
24
  captureSessionContext,
15
25
  createSession,
@@ -28,18 +38,22 @@ export interface DaemonOptions {
28
38
  daemonize?: boolean;
29
39
  /** Whether to restore context from previous session (default: true) */
30
40
  restoreContext?: boolean;
41
+ /** Auto-clone repository for project assignments */
42
+ autoSetup?: boolean;
31
43
  }
32
44
 
33
45
  export class AgentDaemon {
34
46
  private agentName: string;
35
47
  private config: Config;
36
48
  private agentConfig: AgentConfig;
49
+ private runnerConfig: RunnerConfig;
37
50
  private ws: AgentWebSocket | null = null;
38
51
  private heartbeat: Heartbeat | null = null;
39
52
  private token: string | null = null;
40
53
  private agentId: string | null = null;
41
54
  private isRunning = false;
42
55
  private shouldRestoreContext: boolean;
56
+ private autoSetup: boolean;
43
57
 
44
58
  constructor(options: DaemonOptions) {
45
59
  const config = loadConfig();
@@ -50,6 +64,7 @@ export class AgentDaemon {
50
64
  this.config = config;
51
65
  this.agentName = options.name;
52
66
  this.shouldRestoreContext = options.restoreContext !== false;
67
+ this.autoSetup = options.autoSetup === true;
53
68
 
54
69
  // Find or create agent config
55
70
  let agentConfig = config.agents.find((a) => a.name === options.name);
@@ -69,6 +84,18 @@ export class AgentDaemon {
69
84
  if (options.model) agentConfig.model = options.model;
70
85
 
71
86
  this.agentConfig = agentConfig;
87
+
88
+ // Build runner configuration with model resolution
89
+ this.runnerConfig = buildRunnerConfig({
90
+ cliModel: options.model,
91
+ agentModel: agentConfig.model,
92
+ defaultModel: config.defaults.model,
93
+ command: agentConfig.command,
94
+ });
95
+
96
+ const runnerName = getRunnerDisplayName(this.runnerConfig.type);
97
+ console.log(`Runner: ${runnerName}`);
98
+ console.log(`Effective model: ${this.runnerConfig.model}`);
72
99
  }
73
100
 
74
101
  async start(): Promise<void> {
@@ -79,6 +106,27 @@ export class AgentDaemon {
79
106
 
80
107
  console.log(`Starting agent: ${this.agentName}`);
81
108
 
109
+ // Register with hub first (needed for assignment check)
110
+ console.log("Registering with AgentMesh hub...");
111
+ const existingState = getAgentState(this.agentName);
112
+
113
+ const registration = await registerAgent({
114
+ url: this.config.hubUrl,
115
+ apiKey: this.config.apiKey,
116
+ workspace: this.config.workspace,
117
+ agentId: existingState?.agentId || this.agentConfig.agentId,
118
+ agentName: this.agentName,
119
+ model: this.agentConfig.model || this.config.defaults.model,
120
+ });
121
+
122
+ this.agentId = registration.agentId;
123
+ this.token = registration.token;
124
+
125
+ console.log(`Registered as: ${this.agentId}`);
126
+
127
+ // Check assignments and auto-setup workdir if needed (before creating tmux session)
128
+ await this.checkAssignments();
129
+
82
130
  // Check if session already exists
83
131
  const sessionName = getSessionName(this.agentName);
84
132
  const sessionAlreadyExists = sessionExists(sessionName);
@@ -86,10 +134,13 @@ export class AgentDaemon {
86
134
  // Create tmux session if it doesn't exist
87
135
  if (!sessionAlreadyExists) {
88
136
  console.log(`Creating tmux session: ${sessionName}`);
137
+
138
+ // Include runner env vars (e.g., OPENCODE_MODEL) at session creation
89
139
  const created = createSession(
90
140
  this.agentName,
91
141
  this.agentConfig.command,
92
142
  this.agentConfig.workdir,
143
+ this.runnerConfig.env, // Apply model env at process start
93
144
  );
94
145
 
95
146
  if (!created) {
@@ -97,26 +148,10 @@ export class AgentDaemon {
97
148
  }
98
149
  } else {
99
150
  console.log(`Reconnecting to existing session: ${sessionName}`);
151
+ // Update environment for existing session
152
+ updateSessionEnvironment(this.agentName, this.runnerConfig.env);
100
153
  }
101
154
 
102
- // Register with hub
103
- console.log("Registering with AgentMesh hub...");
104
- const existingState = getAgentState(this.agentName);
105
-
106
- const registration = await registerAgent({
107
- url: this.config.hubUrl,
108
- apiKey: this.config.apiKey,
109
- workspace: this.config.workspace,
110
- agentId: existingState?.agentId || this.agentConfig.agentId,
111
- agentName: this.agentName,
112
- model: this.agentConfig.model || this.config.defaults.model,
113
- });
114
-
115
- this.agentId = registration.agentId;
116
- this.token = registration.token;
117
-
118
- console.log(`Registered as: ${this.agentId}`);
119
-
120
155
  // Inject environment variables into tmux session
121
156
  console.log("Injecting environment variables...");
122
157
  updateSessionEnvironment(this.agentName, {
@@ -124,7 +159,7 @@ export class AgentDaemon {
124
159
  AGENTMESH_AGENT_ID: this.agentId,
125
160
  });
126
161
 
127
- // Save state
162
+ // Save state including runtime model info
128
163
  addAgentToState({
129
164
  name: this.agentName,
130
165
  agentId: this.agentId,
@@ -132,6 +167,8 @@ export class AgentDaemon {
132
167
  tmuxSession: sessionName,
133
168
  startedAt: new Date().toISOString(),
134
169
  token: this.token,
170
+ runtimeModel: this.runnerConfig.model,
171
+ runnerType: this.runnerConfig.type,
135
172
  });
136
173
 
137
174
  // Start heartbeat with auto-refresh
@@ -314,4 +351,170 @@ Nudge agent:
314
351
  console.error("Failed to save agent context:", error);
315
352
  }
316
353
  }
354
+
355
+ /**
356
+ * Fetches assignments from HQ and validates workdir setup
357
+ * Uses project.workdir from HQ as source of truth, falls back to helpful instructions
358
+ */
359
+ private async checkAssignments(): Promise<void> {
360
+ if (!this.token) return;
361
+
362
+ try {
363
+ console.log("Fetching project assignments from HQ...");
364
+ const assignments = await fetchAssignments(this.config.hubUrl, this.token);
365
+
366
+ if (assignments.length === 0) {
367
+ console.log("No project assignments found.");
368
+ return;
369
+ }
370
+
371
+ console.log(`Found ${assignments.length} assignment(s):`);
372
+ for (const assignment of assignments) {
373
+ const repoInfo = assignment.repo ? ` -> ${assignment.repo.full_name}` : "";
374
+ const workdirInfo = assignment.project.workdir ? ` [${assignment.project.workdir}]` : "";
375
+ console.log(` - ${assignment.project.name} (${assignment.role})${repoInfo}${workdirInfo}`);
376
+ }
377
+
378
+ // If no CLI workdir specified, try to use project.workdir from HQ
379
+ if (!this.agentConfig.workdir) {
380
+ const assignmentWithWorkdir = assignments.find((a) => a.project.workdir);
381
+ if (assignmentWithWorkdir?.project.workdir) {
382
+ console.log(
383
+ `Using workdir from project settings: ${assignmentWithWorkdir.project.workdir}`,
384
+ );
385
+ this.agentConfig.workdir = assignmentWithWorkdir.project.workdir;
386
+ return;
387
+ }
388
+
389
+ // No project.workdir set, check if we have a repo assignment
390
+ const repoAssignment = assignments.find((a) => a.repo !== null);
391
+ if (repoAssignment) {
392
+ const repo = repoAssignment.repo!;
393
+ const expandedPath = path.join(
394
+ os.homedir(),
395
+ ".agentmesh",
396
+ "workspaces",
397
+ this.config.workspace,
398
+ repoAssignment.project.code.toLowerCase(),
399
+ this.agentName,
400
+ );
401
+ const suggestedPath = `~/.agentmesh/workspaces/${this.config.workspace}/${repoAssignment.project.code.toLowerCase()}/${this.agentName}`;
402
+
403
+ // If --auto-setup is enabled, automatically clone the repo
404
+ if (this.autoSetup) {
405
+ this.agentConfig.workdir = this.setupWorkspace(
406
+ expandedPath,
407
+ repo.url,
408
+ repo.default_branch,
409
+ repoAssignment.project.name,
410
+ );
411
+ return;
412
+ }
413
+
414
+ console.error(`
415
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
416
+ ⚠️ WORKDIR REQUIRED
417
+
418
+ You have a project assignment with a repository, but no workdir is configured.
419
+
420
+ Project: ${repoAssignment.project.name}
421
+ Repo: ${repo.full_name}
422
+ Branch: ${repo.default_branch}
423
+
424
+ Option 1: Set workdir in project settings (recommended)
425
+ - Go to AgentMesh HQ → Projects → ${repoAssignment.project.name} → Settings
426
+ - Set the workdir field to the local path
427
+
428
+ Option 2: Set up workspace manually and pass --workdir:
429
+
430
+ mkdir -p ${suggestedPath}
431
+ git clone ${repo.url} ${suggestedPath}
432
+ cd ${suggestedPath} && git checkout ${repo.default_branch}
433
+
434
+ Then start the agent with:
435
+
436
+ agentmesh start -n ${this.agentName} --workdir ${suggestedPath}
437
+
438
+ Option 3: Use --auto-setup to automatically clone the repository:
439
+
440
+ agentmesh start -n ${this.agentName} --auto-setup
441
+
442
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
443
+ `);
444
+ // No session to clean up - we haven't created it yet
445
+ process.exit(1);
446
+ }
447
+ }
448
+ } catch (error) {
449
+ // Non-fatal: log and continue (HQ might not have assignments feature yet)
450
+ console.log("Could not fetch assignments:", (error as Error).message);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Sets up workspace by cloning repository or using existing clone
456
+ * Returns the absolute path to the workspace
457
+ */
458
+ private setupWorkspace(
459
+ workspacePath: string,
460
+ repoUrl: string,
461
+ defaultBranch: string,
462
+ projectName: string,
463
+ ): string {
464
+ console.log(
465
+ `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
466
+ );
467
+ console.log(`🔧 AUTO-SETUP: Setting up workspace for ${projectName}`);
468
+ console.log(
469
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`,
470
+ );
471
+
472
+ // Check if directory already exists and is a git repo
473
+ const gitDir = path.join(workspacePath, ".git");
474
+ if (fs.existsSync(gitDir)) {
475
+ console.log(`✓ Workspace already exists: ${workspacePath}`);
476
+ console.log(` Updating from remote...`);
477
+
478
+ try {
479
+ // Fetch and checkout the branch
480
+ execSync(`git fetch origin`, { cwd: workspacePath, stdio: "inherit" });
481
+ execSync(`git checkout ${defaultBranch}`, { cwd: workspacePath, stdio: "inherit" });
482
+ execSync(`git pull origin ${defaultBranch}`, { cwd: workspacePath, stdio: "inherit" });
483
+ console.log(`✓ Workspace updated successfully\n`);
484
+ } catch (error) {
485
+ console.warn(`⚠ Could not update workspace: ${(error as Error).message}`);
486
+ console.log(` Continuing with existing state...\n`);
487
+ }
488
+
489
+ return workspacePath;
490
+ }
491
+
492
+ // Create parent directories
493
+ const parentDir = path.dirname(workspacePath);
494
+ if (!fs.existsSync(parentDir)) {
495
+ console.log(`Creating directory: ${parentDir}`);
496
+ fs.mkdirSync(parentDir, { recursive: true });
497
+ }
498
+
499
+ // Clone the repository
500
+ console.log(`Cloning repository...`);
501
+ console.log(` URL: ${repoUrl}`);
502
+ console.log(` Path: ${workspacePath}`);
503
+ console.log(` Branch: ${defaultBranch}\n`);
504
+
505
+ try {
506
+ execSync(`git clone --branch ${defaultBranch} "${repoUrl}" "${workspacePath}"`, {
507
+ stdio: "inherit",
508
+ });
509
+ console.log(`\n✓ Repository cloned successfully`);
510
+ } catch (error) {
511
+ console.error(`\n✗ Failed to clone repository: ${(error as Error).message}`);
512
+ console.error(`\nMake sure you have access to the repository and SSH keys are configured.`);
513
+ // No session to clean up - we haven't created it yet
514
+ process.exit(1);
515
+ }
516
+
517
+ console.log(`✓ Workspace ready: ${workspacePath}\n`);
518
+ return workspacePath;
519
+ }
317
520
  }
@@ -27,9 +27,7 @@ export interface InboxItem {
27
27
  created_at: string;
28
28
  }
29
29
 
30
- export async function registerAgent(
31
- options: RegisterOptions
32
- ): Promise<RegisterResult> {
30
+ export async function registerAgent(options: RegisterOptions): Promise<RegisterResult> {
33
31
  const agentId = options.agentId || randomUUID();
34
32
 
35
33
  const response = await fetch(`${options.url}/api/v1/agents/register`, {
@@ -65,32 +63,27 @@ export async function registerAgent(
65
63
  export async function checkInbox(
66
64
  url: string,
67
65
  workspace: string,
68
- token: string
66
+ token: string,
69
67
  ): Promise<InboxItem[]> {
70
- const response = await fetch(
71
- `${url}/api/v1/workspaces/${workspace}/inbox`,
72
- {
73
- headers: {
74
- Authorization: `Bearer ${token}`,
75
- },
76
- }
77
- );
68
+ const response = await fetch(`${url}/api/v1/workspaces/${workspace}/inbox`, {
69
+ headers: {
70
+ Authorization: `Bearer ${token}`,
71
+ },
72
+ });
78
73
 
79
74
  if (!response.ok) {
80
75
  throw new Error(`Failed to check inbox: ${response.status}`);
81
76
  }
82
77
 
83
78
  const data = await response.json();
84
- return (data.data || []).filter(
85
- (item: InboxItem) => item.status === "pending"
86
- );
79
+ return (data.data || []).filter((item: InboxItem) => item.status === "pending");
87
80
  }
88
81
 
89
82
  export async function sendNudge(
90
83
  url: string,
91
84
  agentId: string,
92
85
  message: string,
93
- token: string
86
+ token: string,
94
87
  ): Promise<boolean> {
95
88
  const response = await fetch(`${url}/api/v1/agents/${agentId}/nudge`, {
96
89
  method: "POST",
@@ -103,3 +96,51 @@ export async function sendNudge(
103
96
 
104
97
  return response.ok;
105
98
  }
99
+
100
+ // ============================================================================
101
+ // Agent Self-Discovery
102
+ // ============================================================================
103
+
104
+ export interface ProjectAssignment {
105
+ assignment_id: string;
106
+ role: string;
107
+ status: string;
108
+ notes: string | null;
109
+ priority: number;
110
+ created_at: string;
111
+ project: {
112
+ project_id: string;
113
+ name: string;
114
+ code: string;
115
+ description: string | null;
116
+ workdir: string | null;
117
+ };
118
+ repo: {
119
+ repo_id: string;
120
+ provider: string;
121
+ full_name: string;
122
+ url: string;
123
+ default_branch: string;
124
+ } | null;
125
+ }
126
+
127
+ /**
128
+ * Fetch the agent's project assignments from HQ
129
+ */
130
+ export async function fetchAssignments(url: string, token: string): Promise<ProjectAssignment[]> {
131
+ const response = await fetch(`${url}/api/v1/agents/me/assignments`, {
132
+ headers: {
133
+ Authorization: `Bearer ${token}`,
134
+ },
135
+ });
136
+
137
+ if (!response.ok) {
138
+ if (response.status === 404) {
139
+ return []; // No assignments
140
+ }
141
+ throw new Error(`Failed to fetch assignments: ${response.status}`);
142
+ }
143
+
144
+ const data = (await response.json()) as { data: ProjectAssignment[] };
145
+ return data.data || [];
146
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Runner Module
3
+ * Handles model resolution, validation, and runner-specific configuration
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export type RunnerType = "opencode" | "claude" | "custom";
13
+
14
+ export interface RunnerConfig {
15
+ type: RunnerType;
16
+ command: string;
17
+ model: string;
18
+ env: Record<string, string>;
19
+ }
20
+
21
+ export interface ModelResolutionInput {
22
+ cliModel?: string;
23
+ agentModel?: string;
24
+ defaultModel: string;
25
+ command: string;
26
+ }
27
+
28
+ // ============================================================================
29
+ // Runner Detection
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Detects the runner type from the command
34
+ */
35
+ export function detectRunner(command: string): RunnerType {
36
+ const cmd = command.toLowerCase().trim();
37
+
38
+ if (cmd === "opencode" || cmd.startsWith("opencode ")) {
39
+ return "opencode";
40
+ }
41
+
42
+ if (cmd === "claude" || cmd.startsWith("claude ")) {
43
+ return "claude";
44
+ }
45
+
46
+ return "custom";
47
+ }
48
+
49
+ // ============================================================================
50
+ // Model Resolution
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Resolves the effective model from CLI > agent config > defaults
55
+ */
56
+ export function resolveModel(input: ModelResolutionInput): string {
57
+ // Priority: CLI flag > agent config > default
58
+ return input.cliModel || input.agentModel || input.defaultModel;
59
+ }
60
+
61
+ // ============================================================================
62
+ // OpenCode Integration
63
+ // ============================================================================
64
+
65
+ let cachedOpenCodeModels: string[] | null = null;
66
+
67
+ /**
68
+ * Gets available OpenCode models (cached)
69
+ */
70
+ export function getOpenCodeModels(): string[] {
71
+ if (cachedOpenCodeModels) {
72
+ return cachedOpenCodeModels;
73
+ }
74
+
75
+ try {
76
+ const output = execSync("opencode models 2>/dev/null", {
77
+ encoding: "utf-8",
78
+ timeout: 10000,
79
+ });
80
+
81
+ cachedOpenCodeModels = output
82
+ .split("\n")
83
+ .map((line) => line.trim())
84
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
85
+
86
+ return cachedOpenCodeModels;
87
+ } catch {
88
+ // OpenCode not available or failed
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Validates that a model is available in OpenCode
95
+ */
96
+ export function validateOpenCodeModel(model: string): { valid: boolean; error?: string } {
97
+ const models = getOpenCodeModels();
98
+
99
+ // If we couldn't get models list, allow any (graceful degradation)
100
+ if (models.length === 0) {
101
+ return { valid: true };
102
+ }
103
+
104
+ if (models.includes(model)) {
105
+ return { valid: true };
106
+ }
107
+
108
+ // Check for partial match (e.g., "claude-sonnet-4" matches "anthropic/claude-sonnet-4")
109
+ const partialMatch = models.find(
110
+ (m) => m.endsWith(`/${model}`) || m === model || m.split("/").pop() === model,
111
+ );
112
+
113
+ if (partialMatch) {
114
+ return { valid: true };
115
+ }
116
+
117
+ return {
118
+ valid: false,
119
+ error: `Model "${model}" not found in OpenCode. Run 'opencode models' to see available models.`,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Normalizes a model name for OpenCode
125
+ * e.g., "claude-sonnet-4" -> "anthropic/claude-sonnet-4" if that's what OpenCode expects
126
+ */
127
+ export function normalizeOpenCodeModel(model: string): string {
128
+ const models = getOpenCodeModels();
129
+
130
+ // Direct match
131
+ if (models.includes(model)) {
132
+ return model;
133
+ }
134
+
135
+ // Try to find full path version
136
+ const fullPath = models.find((m) => m.endsWith(`/${model}`) || m.split("/").pop() === model);
137
+
138
+ return fullPath || model;
139
+ }
140
+
141
+ // ============================================================================
142
+ // Runner Configuration
143
+ // ============================================================================
144
+
145
+ /**
146
+ * Builds the complete runner configuration including environment variables
147
+ */
148
+ export function buildRunnerConfig(input: ModelResolutionInput): RunnerConfig {
149
+ const runnerType = detectRunner(input.command);
150
+ const model = resolveModel(input);
151
+ const env: Record<string, string> = {};
152
+
153
+ switch (runnerType) {
154
+ case "opencode": {
155
+ // Validate model for OpenCode
156
+ const validation = validateOpenCodeModel(model);
157
+ if (!validation.valid) {
158
+ console.warn(`Warning: ${validation.error}`);
159
+ }
160
+
161
+ // Normalize and set OPENCODE_MODEL
162
+ const normalizedModel = normalizeOpenCodeModel(model);
163
+ env.OPENCODE_MODEL = normalizedModel;
164
+ break;
165
+ }
166
+
167
+ case "claude": {
168
+ // Claude CLI uses ANTHROPIC_MODEL or similar
169
+ // For now, just pass the model - Claude CLI will handle it
170
+ env.CLAUDE_MODEL = model;
171
+ break;
172
+ }
173
+
174
+ case "custom":
175
+ // Custom runners don't get automatic model env
176
+ // User is responsible for configuring their tool
177
+ break;
178
+ }
179
+
180
+ return {
181
+ type: runnerType,
182
+ command: input.command,
183
+ model,
184
+ env,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Gets a human-readable runner name
190
+ */
191
+ export function getRunnerDisplayName(runnerType: RunnerType): string {
192
+ switch (runnerType) {
193
+ case "opencode":
194
+ return "OpenCode";
195
+ case "claude":
196
+ return "Claude CLI";
197
+ case "custom":
198
+ return "Custom";
199
+ }
200
+ }
package/src/core/tmux.ts CHANGED
@@ -18,6 +18,9 @@ export function sessionExists(sessionName: string): boolean {
18
18
  export interface SessionEnv {
19
19
  AGENT_TOKEN?: string;
20
20
  AGENTMESH_AGENT_ID?: string;
21
+ OPENCODE_MODEL?: string;
22
+ CLAUDE_MODEL?: string;
23
+ [key: string]: string | undefined;
21
24
  }
22
25
 
23
26
  export function createSession(
@@ -34,17 +37,36 @@ export function createSession(
34
37
  }
35
38
 
36
39
  try {
40
+ // Build environment prefix for the command
41
+ // This ensures env vars are set BEFORE the process starts
42
+ let envPrefix = "";
43
+ if (env) {
44
+ const envParts: string[] = [];
45
+ for (const [key, value] of Object.entries(env)) {
46
+ if (value !== undefined && value !== "") {
47
+ // Escape special characters in value
48
+ const escapedValue = value.replace(/"/g, '\\"');
49
+ envParts.push(`${key}="${escapedValue}"`);
50
+ }
51
+ }
52
+ if (envParts.length > 0) {
53
+ envPrefix = envParts.join(" ") + " ";
54
+ }
55
+ }
56
+
57
+ const fullCommand = `${envPrefix}${command}`;
58
+
37
59
  const args = ["new-session", "-d", "-s", sessionName];
38
60
 
39
61
  if (workdir) {
40
62
  args.push("-c", workdir);
41
63
  }
42
64
 
43
- args.push(command);
65
+ args.push(fullCommand);
44
66
 
45
67
  execSync(`tmux ${args.join(" ")}`);
46
68
 
47
- // Inject environment variables after session creation
69
+ // Also set session environment for any subsequent processes/refreshes
48
70
  if (env) {
49
71
  setSessionEnvironment(sessionName, env);
50
72
  }
@@ -58,13 +80,10 @@ export function createSession(
58
80
 
59
81
  export function setSessionEnvironment(sessionName: string, env: SessionEnv): boolean {
60
82
  try {
61
- if (env.AGENT_TOKEN) {
62
- execSync(`tmux set-environment -t "${sessionName}" AGENT_TOKEN "${env.AGENT_TOKEN}"`);
63
- }
64
- if (env.AGENTMESH_AGENT_ID) {
65
- execSync(
66
- `tmux set-environment -t "${sessionName}" AGENTMESH_AGENT_ID "${env.AGENTMESH_AGENT_ID}"`,
67
- );
83
+ for (const [key, value] of Object.entries(env)) {
84
+ if (value !== undefined && value !== "") {
85
+ execSync(`tmux set-environment -t "${sessionName}" ${key} "${value}"`);
86
+ }
68
87
  }
69
88
  return true;
70
89
  } catch (error) {