@openagents-org/agent-launcher 0.2.151 → 0.2.153

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.
@@ -324,12 +324,15 @@ class CodexAdapter extends BaseAdapter {
324
324
  this._saveSessions();
325
325
  continue;
326
326
  } else {
327
- await this.sendResponse(msgChannel, 'No response generated. Please try again.');
327
+ // Surface the actual reason (turn.failed message, stderr, exit code)
328
+ // instead of a generic "no response" — mirrors the OpenCode adapter
329
+ // so auth/model/network problems are actionable from the chat.
330
+ await this._sendRunFailure(msgChannel, result);
328
331
  return;
329
332
  }
330
333
  } catch (e) {
331
334
  this._log(`Error in subprocess: ${e.message}`);
332
- await this.sendError(msgChannel, `Error: ${e.message}`);
335
+ await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${CodexAdapter._redact(e.message)}`);
333
336
  return;
334
337
  }
335
338
  }
@@ -351,6 +354,7 @@ class CodexAdapter extends BaseAdapter {
351
354
  let hasToolUseSinceLastText = false;
352
355
  let lineBuffer = '';
353
356
  let stderrBuf = '';
357
+ let lastErrorMessage = '';
354
358
  let _pendingLines = Promise.resolve();
355
359
 
356
360
  if (proc.stderr) {
@@ -407,7 +411,12 @@ class CodexAdapter extends BaseAdapter {
407
411
  } else if (eventType === 'turn.failed') {
408
412
  const error = event.error || {};
409
413
  const errMsg = error.message || JSON.stringify(error);
414
+ lastErrorMessage = errMsg;
410
415
  this._log(`Turn failed: ${errMsg}`);
416
+ } else if (eventType === 'error') {
417
+ const errMsg = event.message || JSON.stringify(event);
418
+ lastErrorMessage = errMsg;
419
+ this._log(`Error event: ${errMsg}`);
411
420
  }
412
421
  };
413
422
 
@@ -442,6 +451,7 @@ class CodexAdapter extends BaseAdapter {
442
451
  responseText: responseTexts.join('\n').trim(),
443
452
  exitCode: code,
444
453
  stderr: stderrBuf,
454
+ errorMessage: lastErrorMessage,
445
455
  });
446
456
  });
447
457
 
@@ -452,6 +462,50 @@ class CodexAdapter extends BaseAdapter {
452
462
  });
453
463
  }
454
464
 
465
+ // ------------------------------------------------------------------
466
+ // Failure reporting
467
+ // ------------------------------------------------------------------
468
+
469
+ /**
470
+ * Post a user-visible failure carrying the actual reason a run produced no
471
+ * reply, instead of a generic "No response generated".
472
+ */
473
+ async _sendRunFailure(msgChannel, result) {
474
+ const detail = CodexAdapter._failureDetail(result);
475
+ const body = detail
476
+ ? `Codex failed to complete this run.\n\n> ${detail}`
477
+ : 'Codex finished without producing a reply. Please try again.';
478
+ await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${body}`);
479
+ }
480
+
481
+ /**
482
+ * Pick the most informative failure detail from a run result:
483
+ * turn.failed / error event message → stderr tail → exit code. Redacted.
484
+ */
485
+ static _failureDetail({ errorMessage, stderr, exitCode } = {}) {
486
+ const stderrTail = String(stderr || '').trim().split('\n').slice(-5).join('\n').trim();
487
+ const raw = String(errorMessage || '').trim()
488
+ || stderrTail
489
+ || (exitCode ? `codex exited with code ${exitCode}` : '');
490
+ return CodexAdapter._redact(raw).slice(0, 500).trim();
491
+ }
492
+
493
+ /** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
494
+ static _redact(s) {
495
+ let out = String(s == null ? '' : s);
496
+ out = out
497
+ .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
498
+ .replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
499
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
500
+ .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
501
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
502
+ .replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
503
+ (m, k, sep) => `${k}${sep}[REDACTED]`)
504
+ .replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
505
+ .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
506
+ return out;
507
+ }
508
+
455
509
  // ------------------------------------------------------------------
456
510
  // Direct HTTP mode (fallback when CLI not available)
457
511
  // ------------------------------------------------------------------
@@ -467,11 +521,11 @@ class CodexAdapter extends BaseAdapter {
467
521
  }
468
522
  await this.sendResponse(msgChannel, responseText);
469
523
  } else {
470
- await this.sendResponse(msgChannel, 'No response generated. Please try again.');
524
+ await this._sendRunFailure(msgChannel, {});
471
525
  }
472
526
  } catch (e) {
473
527
  this._log(`Error in direct API: ${e.message}`);
474
- await this.sendError(msgChannel, `Error: ${e.message}`);
528
+ await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${CodexAdapter._redact(e.message)}`);
475
529
  }
476
530
  }
477
531
 
@@ -16,7 +16,7 @@ const { execSync, spawn } = require('child_process');
16
16
 
17
17
  const BaseAdapter = require('./base');
18
18
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
19
- const { buildCursorSkillMd } = require('./workspace-prompt');
19
+ const { buildCursorSkillMd, workspaceSkillName } = require('./workspace-prompt');
20
20
  const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
21
21
 
22
22
  const IS_WINDOWS = process.platform === 'win32';
@@ -308,7 +308,12 @@ class CursorAdapter extends BaseAdapter {
308
308
  const workDir = this.workingDir || defaultAgentWorkdir(this.agentName);
309
309
  const skillDir = path.join(workDir, '.cursor', 'skills');
310
310
  fs.mkdirSync(skillDir, { recursive: true });
311
- const skillFile = path.join(skillDir, 'openagents-workspace.md');
311
+ // Per-agent filename: agents sharing a working directory used to clobber
312
+ // one shared openagents-workspace.md, so uploads carried the identity of
313
+ // whichever agent wrote the file last. Drop that legacy file so a stale
314
+ // copy (with another agent's embedded identity) can never be read again.
315
+ const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`);
316
+ try { fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md')); } catch {}
312
317
 
313
318
  const skillContent = buildCursorSkillMd({
314
319
  endpoint: this.endpoint,
@@ -330,7 +335,20 @@ class CursorAdapter extends BaseAdapter {
330
335
  throw new Error('Cursor CLI not found. Install with: curl https://cursor.com/install -fsSL | bash');
331
336
  }
332
337
 
333
- const cmd = [agentBin, '-p', prompt, '--output-format', 'stream-json', '--trust', '--force'];
338
+ // Cursor has no system-prompt flag, so the CLI would otherwise learn its
339
+ // identity ONLY from whichever workspace skill it happens to open. With a
340
+ // shared working directory several agents' skills coexist (same
341
+ // description, different embedded identity), so every spawn must pin who
342
+ // this agent is and exactly which skill file is its interface — otherwise
343
+ // it can read a sibling's skill and upload under that agent's name.
344
+ const identityHeader =
345
+ `[workspace] You are agent '${this.agentName}'. For ANY workspace ` +
346
+ `operation (messages, files, agents, browser) read and follow ONLY the ` +
347
+ `'${workspaceSkillName(this.agentName)}' skill. Other ` +
348
+ `openagents-workspace-* skills belong to OTHER agents and carry the ` +
349
+ `wrong identity — never use them.\n\n`;
350
+
351
+ const cmd = [agentBin, '-p', identityHeader + prompt, '--output-format', 'stream-json', '--trust', '--force'];
334
352
 
335
353
  // Model selection
336
354
  const model = (this.agentEnv || process.env).CURSOR_MODEL;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Pure helpers for the Claude adapter's decision log ("constraint pinning").
3
+ *
4
+ * The decision log is a per-channel knowledge entry that records every
5
+ * decision the user has confirmed. The adapter re-reads it on every message
6
+ * and injects it into the CLI system prompt — the only position that survives
7
+ * both the CLI's auto-compaction and a session reset. Everything here is
8
+ * side-effect free so it can be unit tested without an adapter instance.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const crypto = require('crypto');
14
+
15
+ /** Max characters a single recap line may occupy before being cut. */
16
+ const RECAP_LINE_MAX_CHARS = 2000;
17
+
18
+ /** Default budget for the pinned-decisions block inside the system prompt. */
19
+ const PINNED_DECISIONS_MAX_CHARS = 4000;
20
+
21
+ /**
22
+ * Canonical knowledge-entry title for a channel's decision log. The slug the
23
+ * backend derives from it is NOT stable (collisions get a numeric suffix), so
24
+ * lookups must match on this exact title, never on the slug.
25
+ */
26
+ function decisionLogTitle(channelName) {
27
+ return `Decisions for channel ${channelName}`;
28
+ }
29
+
30
+ /**
31
+ * Stable hash of the decision log content. null/undefined and '' hash
32
+ * identically so "no entry yet" never reads as a change.
33
+ */
34
+ function hashDecisions(content) {
35
+ return crypto.createHash('sha256').update(String(content || '').trim(), 'utf-8').digest('hex');
36
+ }
37
+
38
+ /**
39
+ * Fingerprint of everything the system prompt pins from the decision log —
40
+ * the entry id AND the content — used to detect that a persistent CLI
41
+ * process was spawned with an outdated prompt. Content alone is not enough:
42
+ * a deleted-and-recreated log can carry identical content under a new id,
43
+ * and a prompt still holding the old id would send updates to a dead entry.
44
+ */
45
+ function decisionFingerprint(entryId, content) {
46
+ return crypto.createHash('sha256')
47
+ .update(`${entryId || ''}\u0000${String(content || '').trim()}`, 'utf-8')
48
+ .digest('hex');
49
+ }
50
+
51
+ /**
52
+ * Pick the channel's decision entry from a knowledge listing: exact-title
53
+ * matches only, earliest created_at wins (concurrent creates produce
54
+ * duplicates — the earliest is the one other turns have been updating).
55
+ * Returns { entry, duplicates } where duplicates counts the extra matches.
56
+ */
57
+ function pickDecisionEntry(entries, channelName) {
58
+ const title = decisionLogTitle(channelName);
59
+ const matches = (entries || []).filter((e) => e && e.title === title);
60
+ if (matches.length === 0) return { entry: null, duplicates: 0 };
61
+ const sorted = matches.slice().sort((a, b) => {
62
+ // ISO-8601 strings compare chronologically as strings; entries without a
63
+ // timestamp are anomalous and sort last so a dated original wins.
64
+ const ka = a.created_at || '\uffff';
65
+ const kb = b.created_at || '\uffff';
66
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
67
+ });
68
+ return { entry: sorted[0], duplicates: matches.length - 1 };
69
+ }
70
+
71
+ /**
72
+ * Fit the decision log into a character budget without ever cutting a line
73
+ * in half. Over budget, whole lines are kept from the head (earliest
74
+ * decisions) and the tail (latest decisions) with the middle dropped, so
75
+ * neither end of the log silently disappears.
76
+ * Returns { text, truncated, omitted }.
77
+ */
78
+ function renderPinnedDecisions(content, { maxChars = PINNED_DECISIONS_MAX_CHARS } = {}) {
79
+ const text = String(content || '').trim();
80
+ if (!text) return { text: '', truncated: false, omitted: 0 };
81
+ if (text.length <= maxChars) return { text, truncated: false, omitted: 0 };
82
+
83
+ const lines = text.split('\n');
84
+ // Reserve room for the omission marker inserted between head and tail.
85
+ const budget = Math.max(maxChars - 80, 0);
86
+ const headBudget = Math.floor(budget / 2);
87
+
88
+ const head = [];
89
+ let used = 0;
90
+ let i = 0;
91
+ for (; i < lines.length; i++) {
92
+ const cost = lines[i].length + 1;
93
+ if (used + cost > headBudget) break;
94
+ head.push(lines[i]);
95
+ used += cost;
96
+ }
97
+
98
+ const tail = [];
99
+ let j = lines.length - 1;
100
+ for (; j >= i; j--) {
101
+ const cost = lines[j].length + 1;
102
+ if (used + cost > budget) break;
103
+ tail.unshift(lines[j]);
104
+ used += cost;
105
+ }
106
+
107
+ const omitted = lines.length - head.length - tail.length;
108
+ if (omitted <= 0) {
109
+ // Everything fit after all (short middle) — no marker needed.
110
+ return { text: [...head, ...tail].join('\n'), truncated: false, omitted: 0 };
111
+ }
112
+ const marker = `[… ${omitted} middle line(s) omitted — read the decision log entry for the full list …]`;
113
+ return {
114
+ text: [...head, marker, ...tail].join('\n'),
115
+ truncated: true,
116
+ omitted,
117
+ };
118
+ }
119
+
120
+ /** A message qualifies for the recap when it is real chat with content. */
121
+ function isRecapEligible(msg, currentMessage) {
122
+ if (!msg) return false;
123
+ const mt = msg.messageType || 'chat';
124
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading') return false;
125
+ const text = (msg.content || '').trim();
126
+ if (!text) return false;
127
+ if (text === currentMessage) return false;
128
+ return true;
129
+ }
130
+
131
+ function formatRecapLine(msg) {
132
+ const text = (msg.content || '').trim();
133
+ const who = msg.senderType === 'human'
134
+ ? (msg.senderName || 'user')
135
+ : (msg.senderName || 'agent');
136
+ const cut = text.length > RECAP_LINE_MAX_CHARS
137
+ ? text.slice(0, RECAP_LINE_MAX_CHARS) + '…'
138
+ : text;
139
+ return `[${who}] ${cut}`;
140
+ }
141
+
142
+ /**
143
+ * Build recap lines from the channel's opening messages (fetched ascending)
144
+ * and its most recent ones (fetched descending, already reversed to
145
+ * chronological). Head keeps the original requirement, tail keeps the live
146
+ * discussion; overlap is removed by event ID. An omission marker is inserted
147
+ * only when the two windows do not overlap, i.e. messages in between were
148
+ * actually dropped.
149
+ */
150
+ function sampleRecap(headMsgs, tailMsgs, currentMessage, { headKeep = 5, tailKeep = 15 } = {}) {
151
+ const head = (headMsgs || [])
152
+ .filter((m) => isRecapEligible(m, currentMessage))
153
+ .slice(0, headKeep);
154
+ const tail = (tailMsgs || [])
155
+ .filter((m) => isRecapEligible(m, currentMessage))
156
+ .slice(-tailKeep);
157
+
158
+ const headIds = new Set(head.map((m) => m.messageId).filter(Boolean));
159
+ const tailDeduped = tail.filter((m) => !m.messageId || !headIds.has(m.messageId));
160
+ const windowsOverlap = tailDeduped.length < tail.length;
161
+
162
+ const lines = head.map(formatRecapLine);
163
+ if (head.length > 0 && tailDeduped.length > 0 && !windowsOverlap) {
164
+ lines.push('[… earlier messages omitted …]');
165
+ }
166
+ lines.push(...tailDeduped.map(formatRecapLine));
167
+ return lines;
168
+ }
169
+
170
+ module.exports = {
171
+ PINNED_DECISIONS_MAX_CHARS,
172
+ RECAP_LINE_MAX_CHARS,
173
+ decisionLogTitle,
174
+ hashDecisions,
175
+ decisionFingerprint,
176
+ pickDecisionEntry,
177
+ renderPinnedDecisions,
178
+ isRecapEligible,
179
+ formatRecapLine,
180
+ sampleRecap,
181
+ };
@@ -18,7 +18,7 @@ const { spawn, execSync } = require('child_process');
18
18
 
19
19
  const BaseAdapter = require('./base');
20
20
  const { formatAttachmentsForPrompt } = require('./utils');
21
- const { buildOpenclawSkillMd, buildOpenclawSystemPrompt } = require('./workspace-prompt');
21
+ const { buildOpenclawSkillMd, buildOpenclawSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
22
22
  const { getRuntimePrefix } = require('../paths');
23
23
 
24
24
  const IS_WINDOWS = process.platform === 'win32';
@@ -151,7 +151,44 @@ class OpenClawAdapter extends BaseAdapter {
151
151
  return;
152
152
  }
153
153
 
154
- const skillName = `openagents-workspace-${this.agentName}`;
154
+ const skillName = workspaceSkillName(this.agentName);
155
+ // Migration — pre-normalization versions installed the skill under the
156
+ // RAW agent name. For names the normalizer rewrites (uppercase,
157
+ // underscores, …) that leaves a stale directory with an outdated
158
+ // channel/token/endpoint that OpenClaw would keep auto-loading alongside
159
+ // the new one (`always: true` metadata). Remove the exact legacy dir when
160
+ // its name differs from the current one; for already-valid names the two
161
+ // coincide and nothing is deleted.
162
+ // The raw name feeds a recursive delete, so it must be provably inert
163
+ // first — agent names are not validated at creation, and a name carrying
164
+ // path separators or `..` would otherwise let the join escape the skills
165
+ // root (rmSync then destroys whatever it lands on). Only names the safe
166
+ // charset allows are considered, and the resolved target must still be a
167
+ // DIRECT child of the skills root. Legacy dirs from weirder names are
168
+ // left in place — stale beats deleting an unproven path.
169
+ const skillsRoot = path.join(wsDir, 'skills');
170
+ const legacyName = `openagents-workspace-${this.agentName}`;
171
+ const legacyDir = path.join(skillsRoot, legacyName);
172
+ if (
173
+ legacyName !== skillName &&
174
+ /^[A-Za-z0-9._-]+$/.test(String(this.agentName)) &&
175
+ path.dirname(path.resolve(legacyDir)) === path.resolve(skillsRoot)
176
+ ) {
177
+ // Ownership proof — the path checks above cannot see filesystem
178
+ // aliasing (case-insensitive lookup on Windows/macOS, trailing-dot
179
+ // stripping on Windows): agent Foo's legacy path can be the SAME
180
+ // directory as agent foo's CURRENT skill. The stored SKILL.md embeds
181
+ // its owner's identity in a quote-delimited phrase, so delete only
182
+ // when that identity is exactly this agent; otherwise leave the dir.
183
+ let owned = false;
184
+ try {
185
+ const stored = fs.readFileSync(path.join(legacyDir, 'SKILL.md'), 'utf-8');
186
+ owned = stored.includes(`agent '${this.agentName}'`);
187
+ } catch {}
188
+ if (owned) {
189
+ try { fs.rmSync(legacyDir, { recursive: true, force: true }); } catch {}
190
+ }
191
+ }
155
192
  const skillDir = path.join(wsDir, 'skills', skillName);
156
193
  fs.mkdirSync(skillDir, { recursive: true });
157
194
 
@@ -13,11 +13,12 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
+ const crypto = require('crypto');
16
17
  const { execSync, spawn } = require('child_process');
17
18
 
18
19
  const BaseAdapter = require('./base');
19
20
  const { formatAttachmentsForPrompt } = require('./utils');
20
- const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt');
21
+ const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
21
22
  const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
22
23
 
23
24
  const IS_WINDOWS = process.platform === 'win32';
@@ -64,7 +65,7 @@ const FAILURE_MESSAGES = {
64
65
  stream_parse_error: 'OpenCode produced output this version could not parse. Update to a supported OpenCode version, or open diagnostics.',
65
66
  empty_response: 'OpenCode finished without producing a final reply. Retry; if it persists, open diagnostics.',
66
67
  process_crashed: 'OpenCode exited unexpectedly. Open diagnostics or retry.',
67
- cwd_unavailable: "OpenCode's working directory is not accessible. Check the agent home directory's permissions.",
68
+ cwd_unavailable: "OpenCode's working directory is not accessible. Check the agent's configured path (or agent home) permissions.",
68
69
  unknown_error: 'OpenCode failed for an undetermined reason. Open diagnostics or retry.',
69
70
  };
70
71
 
@@ -78,12 +79,15 @@ class OpenCodeAdapter extends BaseAdapter {
78
79
  super(opts);
79
80
  this.disabledModules = opts.disabledModules || new Set();
80
81
 
81
- // Agent home directory: ~/.openagents/agents/{agentName}/
82
+ // Agent home directory: ~/.openagents/agents/{agentName}/ — scratch storage
83
+ // for sessions, skills, and opencode config. This is NOT the directory
84
+ // opencode runs in; opencode runs in the configured workingDir (see
85
+ // _resolveCwd) so it can actually reach the project's files. (issue #435)
82
86
  this.agentHome = path.join(os.homedir(), '.openagents', 'agents', this.agentName);
83
87
  fs.mkdirSync(this.agentHome, { recursive: true });
84
88
 
85
89
  this._channelSessions = {};
86
- this._sessionsFile = path.join(this.agentHome, 'sessions.json');
90
+ this._sessionsFile = this._sessionsFileFor();
87
91
  this._migrateSessionsFile();
88
92
  this._loadSessions();
89
93
 
@@ -103,6 +107,11 @@ class OpenCodeAdapter extends BaseAdapter {
103
107
  * Migrate sessions file from old location to agent home.
104
108
  */
105
109
  _migrateSessionsFile() {
110
+ // Only the legacy sessions.json (no workingDir → opencode still runs in
111
+ // agentHome) inherits sessions from the pre-agentHome location. A scoped
112
+ // file belongs to a different --dir, so seeding it with IDs created under
113
+ // agentHome would make every later resume 404.
114
+ if (path.basename(this._sessionsFile) !== 'sessions.json') return;
106
115
  const oldPath = path.join(
107
116
  os.homedir(), '.openagents', 'sessions',
108
117
  `${this.workspaceId}_${this.agentName}_opencode.json`
@@ -137,6 +146,38 @@ class OpenCodeAdapter extends BaseAdapter {
137
146
  } catch {}
138
147
  }
139
148
 
149
+ /**
150
+ * Effective working directory for `opencode run --dir` and the spawn cwd.
151
+ *
152
+ * opencode confines an agent's file access to its --dir worktree root; in
153
+ * headless mode a tool call that reaches outside it is auto-rejected with
154
+ * "user rejected permission". So opencode must run in the agent's configured
155
+ * project path (workingDir), not the per-agent scratch dir (agentHome). With
156
+ * no path configured, fall back to agentHome so pre-existing agents keep
157
+ * running where they always did. (issue #435)
158
+ */
159
+ _resolveCwd() {
160
+ return this.workingDir || this.agentHome;
161
+ }
162
+
163
+ /**
164
+ * Persisted-session-store path, scoped per working directory.
165
+ *
166
+ * An opencode session ID is only resumable against the --dir it was created
167
+ * under. If an agent's workingDir changes (or an agent that previously ran in
168
+ * agentHome gets a path configured), resuming a stored ID under the new --dir
169
+ * 404s with a confusing error. Scope the store by working dir so each project
170
+ * keeps its own IDs; the default (no workingDir) keeps the legacy
171
+ * sessions.json so existing agents retain their context. Files always live
172
+ * under agentHome — never inside the project tree. Mirrors the sha256[:16]
173
+ * per-cwd idiom used by the goose and nanoclaw adapters.
174
+ */
175
+ _sessionsFileFor() {
176
+ if (!this.workingDir) return path.join(this.agentHome, 'sessions.json');
177
+ const slug = crypto.createHash('sha256').update(this.workingDir).digest('hex').slice(0, 16);
178
+ return path.join(this.agentHome, `sessions-${slug}.json`);
179
+ }
180
+
140
181
  async _onControlAction(action, payload) {
141
182
  if (action === 'stop') {
142
183
  const channel = (payload && typeof payload === 'object') ? payload.channel : null;
@@ -168,7 +209,12 @@ class OpenCodeAdapter extends BaseAdapter {
168
209
  */
169
210
  _ensureWorkspaceSkill(channelName) {
170
211
  const skillDir = path.join(this.agentHome, '.opencode', 'skills');
171
- const skillFile = path.join(skillDir, 'openagents-workspace.md');
212
+ // Filename matches the per-agent frontmatter name; remove the legacy
213
+ // shared-name file so a stale duplicate skill is never auto-discovered.
214
+ const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`);
215
+ try {
216
+ fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md'));
217
+ } catch {}
172
218
  try {
173
219
  const content = buildOpenCodeSkillMd({
174
220
  endpoint: this.endpoint,
@@ -642,7 +688,8 @@ class OpenCodeAdapter extends BaseAdapter {
642
688
  }
643
689
 
644
690
  const binary = this._opencodeBinary;
645
- const cmd = [binary, 'run', '--format', 'json', '--dir', this.agentHome];
691
+ const runCwd = this._resolveCwd();
692
+ const cmd = [binary, 'run', '--format', 'json', '--dir', runCwd];
646
693
 
647
694
  // Preflight guarantees a resolvable model; pin it explicitly — without it
648
695
  // opencode hangs waiting for interactive provider/model selection.
@@ -660,7 +707,7 @@ class OpenCodeAdapter extends BaseAdapter {
660
707
  fullPrompt = `${context}\n\n---\n\n${content}`;
661
708
  }
662
709
 
663
- this._log(`CLI: ${binary} run --format json --dir … --model ${model || '(none)'}`);
710
+ this._log(`CLI: ${binary} run --format json --dir ${runCwd} --model ${model || '(none)'}`);
664
711
 
665
712
  const spawnEnv = { ...(this.agentEnv || process.env) };
666
713
 
@@ -677,7 +724,7 @@ class OpenCodeAdapter extends BaseAdapter {
677
724
  const proc = spawn(spawnBinary, spawnArgs, {
678
725
  stdio: ['pipe', 'pipe', 'pipe'],
679
726
  env: spawnEnv,
680
- cwd: this.agentHome,
727
+ cwd: runCwd,
681
728
  timeout: TIMEOUT_MS,
682
729
  detached: !IS_WINDOWS,
683
730
  // Never let a console window appear. Besides being ugly, an attached
@@ -825,10 +872,11 @@ class OpenCodeAdapter extends BaseAdapter {
825
872
  const cred = this._credentialState();
826
873
  if (cred === 'missing') return { ok: false, category: 'credential_missing' };
827
874
 
875
+ const runCwd = this._resolveCwd();
828
876
  try {
829
- fs.accessSync(this.agentHome, fs.constants.R_OK | fs.constants.W_OK);
877
+ fs.accessSync(runCwd, fs.constants.R_OK | fs.constants.W_OK);
830
878
  } catch {
831
- return { ok: false, category: 'cwd_unavailable', diagnostic: `cannot access ${this.agentHome}` };
879
+ return { ok: false, category: 'cwd_unavailable', diagnostic: `cannot access ${runCwd}` };
832
880
  }
833
881
 
834
882
  return { ok: true, version: probe.version, versionClass: vclass, model, credential: cred };