@agentmeshhq/agent 0.1.16 → 0.2.0

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/src/cli/attach.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { execSync } from "node:child_process";
2
1
  import pc from "picocolors";
3
- import { getAgentState } from "../config/loader.js";
4
2
  import { attachSession, getSessionName, sessionExists } from "../core/tmux.js";
5
3
 
6
4
  export function attach(name: string): void {
@@ -9,25 +7,6 @@ export function attach(name: string): void {
9
7
  process.exit(1);
10
8
  }
11
9
 
12
- // Check if this is a sandbox agent
13
- const localAgent = getAgentState(name);
14
-
15
- if (localAgent?.sandboxContainer) {
16
- // Sandbox agent - attach via docker exec
17
- console.log(`Attaching to sandbox container ${localAgent.sandboxContainer}...`);
18
- console.log(pc.dim("Detach with: Ctrl+B, D\n"));
19
-
20
- try {
21
- execSync(`docker exec -it ${localAgent.sandboxContainer} agentmesh attach ${name}`, {
22
- stdio: "inherit",
23
- });
24
- } catch {
25
- // execSync throws on non-zero exit, but that's expected when detaching
26
- }
27
- return;
28
- }
29
-
30
- // Host agent - attach via tmux
31
10
  const sessionName = getSessionName(name);
32
11
 
33
12
  if (!sessionExists(sessionName)) {
package/src/cli/index.ts CHANGED
@@ -53,7 +53,8 @@ program
53
53
  .option("-w, --workdir <path>", "Working directory")
54
54
  .option("-m, --model <model>", "Model identifier")
55
55
  .option("-f, --foreground", "Run in foreground (blocking)")
56
- .option("--no-context", "Start fresh without restoring previous context")
56
+ .option("--restore-context", "Restore context from previous session (default: disabled)")
57
+ .option("--worker", "Enable auto-nudge and restart for worker agents (default: disabled)")
57
58
  .option("--auto-setup", "Auto-clone repository for project assignments")
58
59
  .option("--serve", "Run opencode serve instead of tmux TUI (for Integration Service)")
59
60
  .option("--serve-port <port>", "Port for opencode serve (default: 3001)", "3001")
package/src/cli/nudge.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import pc from "picocolors";
3
2
  import { getAgentState, loadConfig, loadState } from "../config/loader.js";
4
3
  import { registerAgent } from "../core/registry.js";
@@ -25,23 +24,6 @@ export async function nudge(name: string, message: string): Promise<void> {
25
24
  // Check if this is a local agent
26
25
  const localAgent = getAgentState(name);
27
26
 
28
- // Sandbox agent - route nudge through docker exec
29
- if (localAgent?.sandboxContainer) {
30
- const result = spawnSync(
31
- "docker",
32
- ["exec", localAgent.sandboxContainer, "agentmesh", "nudge", name, message],
33
- { encoding: "utf-8", stdio: "pipe" },
34
- );
35
-
36
- if (result.status === 0) {
37
- console.log(pc.green(`Nudged "${name}" in sandbox.`));
38
- } else {
39
- console.log(pc.red(`Failed to nudge "${name}" in sandbox: ${result.stderr}`));
40
- }
41
- return;
42
- }
43
-
44
- // Local agent with tmux session on host
45
27
  if (localAgent && sessionExists(getSessionName(name))) {
46
28
  // Local nudge via tmux send-keys
47
29
  const formatted = `[AgentMesh] Nudge from CLI:
package/src/cli/start.ts CHANGED
@@ -12,7 +12,8 @@ export interface StartOptions {
12
12
  workdir?: string;
13
13
  model?: string;
14
14
  foreground?: boolean;
15
- noContext?: boolean;
15
+ restoreContext?: boolean;
16
+ worker?: boolean;
16
17
  autoSetup?: boolean;
17
18
  /** Run opencode serve instead of tmux TUI (for Integration Service) */
18
19
  serve?: boolean;
@@ -56,7 +57,8 @@ export async function start(options: StartOptions): Promise<void> {
56
57
  try {
57
58
  const daemon = new AgentDaemon({
58
59
  ...options,
59
- restoreContext: !options.noContext,
60
+ restoreContext: options.restoreContext,
61
+ worker: options.worker,
60
62
  autoSetup: options.autoSetup,
61
63
  });
62
64
  await daemon.start();
@@ -80,7 +82,8 @@ export async function start(options: StartOptions): Promise<void> {
80
82
  if (options.command) args.push("--command", options.command);
81
83
  if (options.workdir) args.push("--workdir", options.workdir);
82
84
  if (options.model) args.push("--model", options.model);
83
- if (options.noContext) args.push("--no-context");
85
+ if (options.restoreContext) args.push("--restore-context");
86
+ if (options.worker) args.push("--worker");
84
87
  if (options.autoSetup) args.push("--auto-setup");
85
88
  if (options.serve) {
86
89
  args.push("--serve");
@@ -59,6 +59,8 @@ export interface AgentState {
59
59
  stuckSince?: string;
60
60
  /** Current agent status */
61
61
  status?: AgentStatus;
62
+ /** OpenCode session ID for native session resume */
63
+ opencodeSessionId?: string;
62
64
  }
63
65
 
64
66
  export interface State {
@@ -16,6 +16,7 @@ import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } fro
16
16
  import { checkInbox, fetchAssignments, registerAgent, type ServerContext } from "./registry.js";
17
17
  import { buildRunnerConfig, getRunnerDisplayName, type RunnerConfig } from "./runner.js";
18
18
  import { DockerSandbox } from "./sandbox.js";
19
+ import { getLatestSessionId, snapshotSessionId, waitForNewSessionId } from "./session-id.js";
19
20
  import {
20
21
  captureSessionContext,
21
22
  captureSessionOutput,
@@ -56,8 +57,10 @@ export interface DaemonOptions {
56
57
  workdir?: string;
57
58
  model?: string;
58
59
  daemonize?: boolean;
59
- /** Whether to restore context from previous session (default: true) */
60
+ /** Whether to restore context from previous session (default: false) */
60
61
  restoreContext?: boolean;
62
+ /** Only send nudges/restart for worker agents (default: false) */
63
+ worker?: boolean;
61
64
  /** Auto-clone repository for project assignments */
62
65
  autoSetup?: boolean;
63
66
  /** Run opencode serve instead of tmux TUI (for Integration Service) */
@@ -86,6 +89,7 @@ export class AgentDaemon {
86
89
  private isRunning = false;
87
90
  private assignedProject: string | undefined;
88
91
  private shouldRestoreContext: boolean;
92
+ private isWorkerAgent: boolean;
89
93
  private autoSetup: boolean;
90
94
  private serveMode: boolean;
91
95
  private servePort: number;
@@ -97,6 +101,9 @@ export class AgentDaemon {
97
101
  private sandbox: DockerSandbox | null = null;
98
102
  private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
99
103
  private serverContext: ServerContext | undefined;
104
+ // Session resume tracking
105
+ private _preStartSessionId: string | null | undefined;
106
+ private _attemptedResumeSessionId: string | undefined;
100
107
  // Auto-restart tracking
101
108
  private restartCount = 0;
102
109
  private lastStableTime: Date | null = null;
@@ -111,11 +118,12 @@ export class AgentDaemon {
111
118
 
112
119
  // Ensure config has required fields with defaults
113
120
  if (!config.agents) config.agents = [];
114
- if (!config.defaults) config.defaults = { command: "opencode", model: "claude-sonnet-4" };
121
+ if (!config.defaults) config.defaults = { command: "opencode", model: "claude-sonnet-4-5-20250929" };
115
122
 
116
123
  this.config = config;
117
124
  this.agentName = options.name;
118
- this.shouldRestoreContext = options.restoreContext !== false;
125
+ this.shouldRestoreContext = options.restoreContext === true;
126
+ this.isWorkerAgent = options.worker === true;
119
127
  this.autoSetup = options.autoSetup === true;
120
128
 
121
129
  // Find or create agent config
@@ -231,6 +239,20 @@ export class AgentDaemon {
231
239
 
232
240
  // Create tmux session if it doesn't exist
233
241
  if (!sessionAlreadyExists) {
242
+ // Load saved context to check for OpenCode session ID (for native resume)
243
+ let savedSessionId: string | undefined;
244
+ if (this.shouldRestoreContext && this.agentId) {
245
+ const savedContext = loadContext(this.agentId);
246
+ savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
247
+ if (savedSessionId) {
248
+ console.log(`[CONTEXT] Found saved OpenCode session ID: ${savedSessionId}`);
249
+ }
250
+ }
251
+
252
+ // Snapshot the latest session ID in logs BEFORE starting OpenCode.
253
+ // This lets us detect whether OpenCode actually resumed vs created a new session.
254
+ const preStartSessionId = snapshotSessionId(this.agentName);
255
+
234
256
  console.log(`Creating tmux session: ${sessionName}`);
235
257
 
236
258
  // Include runner env vars (e.g., OPENCODE_MODEL) at session creation
@@ -239,11 +261,16 @@ export class AgentDaemon {
239
261
  this.agentConfig.command,
240
262
  this.agentConfig.workdir,
241
263
  this.runnerConfig.env, // Apply model env at process start
264
+ savedSessionId, // Resume OpenCode session if available
242
265
  );
243
266
 
244
267
  if (!created) {
245
268
  throw new Error("Failed to create tmux session");
246
269
  }
270
+
271
+ // Store pre-start snapshot for fallback detection later
272
+ this._preStartSessionId = preStartSessionId;
273
+ this._attemptedResumeSessionId = savedSessionId;
247
274
  } else {
248
275
  console.log(`Reconnecting to existing session: ${sessionName}`);
249
276
  // Update environment for existing session
@@ -371,10 +398,61 @@ export class AgentDaemon {
371
398
  console.log("Checking for previous context...");
372
399
  const savedContext = loadContext(this.agentId);
373
400
  if (savedContext) {
374
- console.log(`Restoring context from ${savedContext.savedAt}`);
375
- // Wait a moment for the session to be ready
376
- await new Promise((resolve) => setTimeout(resolve, 1000));
377
- injectRestoredContext(this.agentName, savedContext);
401
+ if (this._attemptedResumeSessionId && !this.serveMode && !this.sandboxMode) {
402
+ // Native session resume was attempted verify it worked.
403
+ // Wait for OpenCode to write a NEW session entry to logs.
404
+ // If resume succeeded, it reuses the session (no new entry).
405
+ // If resume failed, OpenCode creates a new session (new entry appears).
406
+ const newSessionId = await waitForNewSessionId(
407
+ this.agentName,
408
+ this._preStartSessionId ?? null,
409
+ 15000,
410
+ );
411
+
412
+ if (!newSessionId) {
413
+ // No new session appeared in logs. Could mean:
414
+ // a) Resume succeeded (OpenCode reused session, no new "created" log)
415
+ // b) OpenCode is sitting at splash (session not found, no new session created)
416
+ const health = isSessionHealthy(this.agentName);
417
+ const currentSessionId = getLatestSessionId(this.agentName);
418
+
419
+ if (!health.healthy) {
420
+ // OpenCode died — clear stale session ID to prevent restart loop
421
+ console.log(`[CONTEXT] Fallback: OpenCode not healthy, injecting text summary.`);
422
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
423
+ saveContext(savedContext);
424
+ injectRestoredContext(this.agentName, savedContext);
425
+ } else if (currentSessionId === this._attemptedResumeSessionId) {
426
+ // The session ID we tried to resume is still the latest — resume worked
427
+ console.log(`[CONTEXT] Resumed OpenCode session ${this._attemptedResumeSessionId}`);
428
+ } else {
429
+ // Pane is alive but no matching session ID in logs — OpenCode is at splash
430
+ console.log(
431
+ `[CONTEXT] Fallback: session not found in OpenCode. Injecting text summary.`,
432
+ );
433
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
434
+ saveContext(savedContext);
435
+ injectRestoredContext(this.agentName, savedContext);
436
+ }
437
+ } else if (newSessionId === this._attemptedResumeSessionId) {
438
+ // OpenCode logged the same session ID — resume succeeded
439
+ console.log(`[CONTEXT] Resumed OpenCode session ${this._attemptedResumeSessionId}`);
440
+ } else {
441
+ // OpenCode created a different session — resume failed, fallback to text.
442
+ // Update saved context with new session ID to prevent restart loop.
443
+ console.log(
444
+ `[CONTEXT] Fallback: resume failed (expected ${this._attemptedResumeSessionId}, got ${newSessionId}). Injecting text summary.`,
445
+ );
446
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: newSessionId };
447
+ saveContext(savedContext);
448
+ injectRestoredContext(this.agentName, savedContext);
449
+ }
450
+ } else {
451
+ // No session ID saved or non-tmux mode — use text injection
452
+ console.log(`Restoring context from ${savedContext.savedAt}`);
453
+ await new Promise((resolve) => setTimeout(resolve, 1000));
454
+ injectRestoredContext(this.agentName, savedContext);
455
+ }
378
456
  }
379
457
  }
380
458
 
@@ -491,6 +569,11 @@ ${lastOutput}
491
569
 
492
570
  fs.appendFileSync(logFile, crashLog);
493
571
 
572
+ // Save context (including session ID) before restart attempt
573
+ if (this.agentId) {
574
+ this.saveAgentContext();
575
+ }
576
+
494
577
  // Check if we can restart
495
578
  if (this.restartCount < MAX_RESTART_ATTEMPTS) {
496
579
  this.restartCount++;
@@ -557,34 +640,39 @@ ${lastOutput}
557
640
  });
558
641
  }
559
642
 
560
- // If we haven't sent a nudge yet, send one
561
- if (!this.nudgeSentAt) {
562
- console.log(`[HEALTH] Sending nudge to unstick agent...`);
563
-
564
- const nudgeMessage =
565
- progress.status === "permission_blocked"
566
- ? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
567
- : "Please continue with your current task.";
568
-
569
- const sent = sendNudge(this.agentName, nudgeMessage);
570
- if (sent) {
571
- this.nudgeSentAt = now;
572
- console.log(`[HEALTH] Nudge sent successfully`);
573
- } else {
574
- console.log(`[HEALTH] Failed to send nudge`);
643
+ // Only nudge worker agents - others restart immediately
644
+ if (this.isWorkerAgent) {
645
+ // If we haven't sent a nudge yet, send one
646
+ if (!this.nudgeSentAt) {
647
+ console.log(`[HEALTH] Sending nudge to worker agent...`);
648
+
649
+ const nudgeMessage =
650
+ progress.status === "permission_blocked"
651
+ ? "Please continue with your task. If you see a permission prompt, try an alternative approach that doesn't require that permission."
652
+ : "Please continue with your current task.";
653
+
654
+ const sent = sendNudge(this.agentName, nudgeMessage);
655
+ if (sent) {
656
+ this.nudgeSentAt = now;
657
+ console.log(`[HEALTH] Nudge sent successfully`);
658
+ } else {
659
+ console.log(`[HEALTH] Failed to send nudge`);
660
+ }
661
+ return;
575
662
  }
576
- return;
577
- }
578
663
 
579
- // Check if enough time has passed since nudge
580
- const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
581
- if (timeSinceNudge < NUDGE_WAIT_MS) {
582
- // Still waiting for agent to respond to nudge
583
- return;
664
+ // Check if enough time has passed since nudge
665
+ const timeSinceNudge = now.getTime() - this.nudgeSentAt.getTime();
666
+ if (timeSinceNudge < NUDGE_WAIT_MS) {
667
+ // Still waiting for agent to respond to nudge
668
+ return;
669
+ }
584
670
  }
585
671
 
586
- // Agent still stuck after nudge - trigger restart
587
- console.log(`[HEALTH] Agent still stuck after nudge, triggering restart...`);
672
+ // Agent still stuck - trigger restart (or restart immediately if not a worker)
673
+ console.log(
674
+ `[HEALTH] Agent still stuck${this.isWorkerAgent ? " after nudge" : ""}, triggering restart...`,
675
+ );
588
676
  this.stuckSince = null;
589
677
  this.nudgeSentAt = null;
590
678
 
@@ -638,12 +726,25 @@ ${lastOutput}
638
726
  sandboxContainer: containerName,
639
727
  });
640
728
  } else {
641
- // Non-sandbox restart - just recreate tmux session
729
+ // Non-sandbox restart load saved session ID for native resume
730
+ let savedSessionId: string | undefined;
731
+ let savedContext = null;
732
+ if (this.agentId) {
733
+ savedContext = loadContext(this.agentId);
734
+ savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
735
+ if (savedSessionId) {
736
+ console.log(`[RESTART] Attempting to resume OpenCode session: ${savedSessionId}`);
737
+ }
738
+ }
739
+
740
+ const preRestartSessionId = snapshotSessionId(this.agentName);
741
+
642
742
  const created = createSession(
643
743
  this.agentName,
644
744
  this.agentConfig.command,
645
745
  this.agentConfig.workdir,
646
746
  this.runnerConfig.env,
747
+ savedSessionId,
647
748
  );
648
749
 
649
750
  if (!created) {
@@ -656,6 +757,39 @@ ${lastOutput}
656
757
  AGENTMESH_AGENT_ID: this.agentId!,
657
758
  ...this.runnerConfig.env,
658
759
  });
760
+
761
+ // Verify native resume and fallback if needed
762
+ if (savedSessionId && savedContext) {
763
+ const newSessionId = await waitForNewSessionId(this.agentName, preRestartSessionId, 15000);
764
+
765
+ if (!newSessionId) {
766
+ const health = isSessionHealthy(this.agentName);
767
+ const currentSessionId = getLatestSessionId(this.agentName);
768
+
769
+ if (!health.healthy) {
770
+ console.log(`[RESTART] Fallback: OpenCode not healthy, injecting text summary.`);
771
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
772
+ saveContext(savedContext);
773
+ injectRestoredContext(this.agentName, savedContext);
774
+ } else if (currentSessionId === savedSessionId) {
775
+ console.log(`[RESTART] Resumed OpenCode session ${savedSessionId}`);
776
+ } else {
777
+ console.log(`[RESTART] Fallback: session not found. Injecting text summary.`);
778
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: undefined };
779
+ saveContext(savedContext);
780
+ injectRestoredContext(this.agentName, savedContext);
781
+ }
782
+ } else if (newSessionId === savedSessionId) {
783
+ console.log(`[RESTART] Resumed OpenCode session ${savedSessionId}`);
784
+ } else {
785
+ console.log(
786
+ `[RESTART] Fallback: resume failed (got ${newSessionId}). Injecting text summary.`,
787
+ );
788
+ savedContext.custom = { ...savedContext.custom, opencodeSessionId: newSessionId };
789
+ saveContext(savedContext);
790
+ injectRestoredContext(this.agentName, savedContext);
791
+ }
792
+ }
659
793
  }
660
794
 
661
795
  // Wait for session to be ready
@@ -792,6 +926,16 @@ ${lastOutput}
792
926
  await new Promise((resolve) => setTimeout(resolve, 2000));
793
927
 
794
928
  console.log(`opencode serve started on http://0.0.0.0:${this.servePort}`);
929
+
930
+ // Store saved session ID for integration service reuse
931
+ if (this.shouldRestoreContext && this.agentId) {
932
+ const savedContext = loadContext(this.agentId);
933
+ const savedSessionId = savedContext?.custom?.opencodeSessionId as string | undefined;
934
+ if (savedSessionId) {
935
+ console.log(`[SERVE] Saved OpenCode session available for reuse: ${savedSessionId}`);
936
+ updateAgentInState(this.agentName, { opencodeSessionId: savedSessionId });
937
+ }
938
+ }
795
939
  }
796
940
 
797
941
  /**
@@ -961,6 +1105,13 @@ Logs: docker logs ${containerName}
961
1105
  };
962
1106
  }
963
1107
 
1108
+ // Capture OpenCode session ID for native resume on restart
1109
+ const sessionId = getLatestSessionId(this.agentName);
1110
+ if (sessionId) {
1111
+ context.custom = { ...context.custom, opencodeSessionId: sessionId };
1112
+ console.log(`[CONTEXT] Captured OpenCode session ID: ${sessionId}`);
1113
+ }
1114
+
964
1115
  // Save updated context
965
1116
  saveContext(context);
966
1117
  console.log(`Context saved for agent ${this.agentName}`);
@@ -48,7 +48,7 @@ export async function registerAgent(options: RegisterOptions): Promise<RegisterR
48
48
  model: options.model,
49
49
  capabilities: options.capabilities || ["coding", "review", "debugging"],
50
50
  workspace: options.workspace,
51
- restore_context: options.restoreContext ?? true,
51
+ restore_context: options.restoreContext ?? false,
52
52
  }),
53
53
  });
54
54
 
@@ -122,40 +122,17 @@ export function validateOpenCodeModel(model: string): { valid: boolean; error?:
122
122
 
123
123
  // Common model aliases to their full OpenCode names
124
124
  const MODEL_ALIASES: Record<string, string> = {
125
- // Anthropic — Claude
126
125
  "claude-sonnet-4": "anthropic/claude-sonnet-4-5",
127
- "claude-sonnet-4-0": "anthropic/claude-sonnet-4-0",
128
126
  "claude-sonnet-4-5": "anthropic/claude-sonnet-4-5",
129
- "claude-sonnet-4-6": "anthropic/claude-sonnet-4-6",
130
127
  "claude-opus-4": "anthropic/claude-opus-4-5",
131
- "claude-opus-4-0": "anthropic/claude-opus-4-0",
132
- "claude-opus-4-1": "anthropic/claude-opus-4-1",
133
128
  "claude-opus-4-5": "anthropic/claude-opus-4-5",
134
- "claude-opus-4-6": "anthropic/claude-opus-4-6",
135
129
  "claude-haiku-4": "anthropic/claude-haiku-4-5",
136
130
  "claude-haiku-4-5": "anthropic/claude-haiku-4-5",
137
- "claude-3-5-sonnet": "anthropic/claude-3-5-sonnet-20241022",
138
- "claude-3-5-haiku": "anthropic/claude-3-5-haiku-latest",
139
- "claude-3-7-sonnet": "anthropic/claude-3-7-sonnet-latest",
140
-
141
- // OpenAI — GPT & Codex
142
- "gpt-5.2": "openai/gpt-5.2",
143
- "gpt-5.3-codex": "openai/gpt-5.3-codex",
144
- "gpt-5.2-codex": "openai/gpt-5.2-codex",
145
- "gpt-5.1-codex": "openai/gpt-5.1-codex",
146
- "gpt-5.1-codex-max": "openai/gpt-5.1-codex-max",
147
- "gpt-5.1-codex-mini": "openai/gpt-5.1-codex-mini",
148
- "gpt-5-codex": "openai/gpt-5-codex",
149
- "codex-mini": "openai/codex-mini-latest",
150
- codex: "openai/gpt-5.3-codex",
151
-
152
- // xAI — Grok
153
- "grok-4": "xai/grok-4",
154
- "grok-4-fast": "xai/grok-4-fast",
155
- "grok-3": "xai/grok-3",
156
- "grok-3-fast": "xai/grok-3-fast",
157
- "grok-3-mini": "xai/grok-3-mini",
158
- "grok-code": "xai/grok-code-fast-1",
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",
159
136
  };
160
137
 
161
138
  /**
@@ -225,11 +225,8 @@ export class DockerSandbox {
225
225
  // Image and command
226
226
  args.push(this.config.image);
227
227
 
228
- // Command: custom command > serve mode > tail
229
- if (this.config.command && this.config.command.length > 0) {
230
- // Custom command (e.g., agentmesh start inside container)
231
- args.push(...this.config.command);
232
- } else if (this.config.serveMode) {
228
+ // Command: either serve mode or keep container alive for tmux-style attach
229
+ if (this.config.serveMode) {
233
230
  args.push(
234
231
  "opencode",
235
232
  "serve",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * OpenCode Session ID Utilities
3
+ *
4
+ * Parses OpenCode log files to extract the active session ID (ses_XXXXX).
5
+ * Used for native session resumption via `opencode --session <id> --continue`.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ const SESSION_ID_PATTERN = /service=session\s+id=(ses_[A-Za-z0-9]+)/;
13
+
14
+ function getLogDir(agentName: string): string {
15
+ return path.join(os.homedir(), ".agentmesh", "opencode-data", agentName, "opencode", "log");
16
+ }
17
+
18
+ /**
19
+ * Gets the latest OpenCode session ID from agent log files.
20
+ *
21
+ * Parses log files in ~/.agentmesh/opencode-data/<agentName>/opencode/log/
22
+ * for the pattern: `service=session id=ses_XXXXX ... created`
23
+ *
24
+ * @returns The session ID string (e.g. "ses_365c3ec5bffeAV7qWirhNr1sU9") or null
25
+ */
26
+ export function getLatestSessionId(agentName: string): string | null {
27
+ const logDir = getLogDir(agentName);
28
+
29
+ if (!fs.existsSync(logDir)) {
30
+ return null;
31
+ }
32
+
33
+ try {
34
+ // List log files sorted by name (they're timestamped, so newest last)
35
+ const logFiles = fs
36
+ .readdirSync(logDir)
37
+ .filter((f) => f.endsWith(".log"))
38
+ .sort();
39
+
40
+ if (logFiles.length === 0) {
41
+ return null;
42
+ }
43
+
44
+ // Search from newest log file backwards
45
+ for (let i = logFiles.length - 1; i >= 0; i--) {
46
+ const logPath = path.join(logDir, logFiles[i]);
47
+ const content = fs.readFileSync(logPath, "utf-8");
48
+
49
+ // Find all session creation lines and take the last one
50
+ const lines = content.split("\n");
51
+ let lastSessionId: string | null = null;
52
+
53
+ for (const line of lines) {
54
+ if (line.includes("service=session") && line.includes("created")) {
55
+ const match = line.match(SESSION_ID_PATTERN);
56
+ if (match) {
57
+ lastSessionId = match[1];
58
+ }
59
+ }
60
+ }
61
+
62
+ if (lastSessionId) {
63
+ return lastSessionId;
64
+ }
65
+ }
66
+
67
+ return null;
68
+ } catch (error) {
69
+ console.error(`[SESSION-ID] Failed to parse logs for ${agentName}:`, error);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Waits for OpenCode to write a NEW session ID to logs (different from `previousId`).
76
+ * Polls log files up to `maxWaitMs` with `intervalMs` between checks.
77
+ *
78
+ * This solves the race condition where we read logs before OpenCode has started
79
+ * and see the old session ID, falsely concluding resume succeeded.
80
+ *
81
+ * @returns The new session ID, or null if timeout or no new session appeared
82
+ */
83
+ export async function waitForNewSessionId(
84
+ agentName: string,
85
+ previousId: string | null,
86
+ maxWaitMs = 15000,
87
+ intervalMs = 1000,
88
+ ): Promise<string | null> {
89
+ const deadline = Date.now() + maxWaitMs;
90
+
91
+ while (Date.now() < deadline) {
92
+ const currentId = getLatestSessionId(agentName);
93
+
94
+ // If we see a different session ID than before, OpenCode has started
95
+ if (currentId && currentId !== previousId) {
96
+ return currentId;
97
+ }
98
+
99
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Snapshots the latest session ID BEFORE starting OpenCode.
107
+ * Used to detect whether OpenCode created a new session or resumed the requested one.
108
+ */
109
+ export function snapshotSessionId(agentName: string): string | null {
110
+ return getLatestSessionId(agentName);
111
+ }
package/src/core/tmux.ts CHANGED
@@ -31,6 +31,7 @@ export function createSession(
31
31
  command: string,
32
32
  workdir?: string,
33
33
  env?: SessionEnv,
34
+ opencodeSessionId?: string,
34
35
  ): boolean {
35
36
  const sessionName = getSessionName(agentName);
36
37
 
@@ -92,6 +93,17 @@ export function createSession(
92
93
  }
93
94
  }
94
95
 
96
+ // Append --session --continue flags for native session resume
97
+ if (
98
+ opencodeSessionId &&
99
+ (finalCommand === "opencode" || finalCommand.startsWith("opencode ")) &&
100
+ !finalCommand.includes("--session") &&
101
+ !finalCommand.includes("--continue")
102
+ ) {
103
+ finalCommand = `${finalCommand} --session ${opencodeSessionId} --continue`;
104
+ console.log(`[TMUX] Resuming OpenCode session: ${opencodeSessionId}`);
105
+ }
106
+
95
107
  const fullCommand = `${envPrefix}${finalCommand}`;
96
108
 
97
109
  // Set reasonable terminal size for TUI applications
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { execSync, spawnSync } from "node:child_process";
9
- import fs from "node:fs";
10
9
  import { captureSessionOutput } from "./tmux.js";
11
10
 
12
11
  export type WatchdogStatus = "active" | "idle" | "stuck" | "permission_blocked";
@@ -139,12 +138,8 @@ export function getLastActivityTime(agentName: string, containerName?: string):
139
138
  }
140
139
  logLine = result.stdout.trim();
141
140
  } else {
142
- // Non-sandbox mode: read from agent-isolated log directory
143
- // Each agent has its own XDG_DATA_HOME at ~/.agentmesh/opencode-data/<agent>/
144
- const agentLogDir = `${process.env.HOME}/.agentmesh/opencode-data/${agentName}/opencode/log`;
145
- const sharedLogDir = `${process.env.HOME}/.local/share/opencode/log`;
146
- // Prefer agent-specific logs, fall back to shared dir for backwards compatibility
147
- const logDir = fs.existsSync(agentLogDir) ? agentLogDir : sharedLogDir;
141
+ // Non-sandbox mode: read from local logs
142
+ const logDir = `${process.env.HOME}/.local/share/opencode/log`;
148
143
  const result = spawnSync(
149
144
  "sh",
150
145
  ["-c", `ls -t ${logDir}/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null`],