@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.
- package/package.json +1 -1
- package/src/adapters/claude.js +358 -180
- package/src/adapters/codex.js +58 -4
- package/src/adapters/cursor.js +21 -3
- package/src/adapters/decision-log.js +181 -0
- package/src/adapters/openclaw.js +39 -2
- package/src/adapters/opencode.js +58 -10
- package/src/adapters/workspace-prompt.js +255 -29
- package/src/installer.js +59 -2
- package/src/mcp-server.js +112 -0
- package/src/paths.js +57 -0
- package/src/workspace-client.js +59 -14
package/src/adapters/codex.js
CHANGED
|
@@ -324,12 +324,15 @@ class CodexAdapter extends BaseAdapter {
|
|
|
324
324
|
this._saveSessions();
|
|
325
325
|
continue;
|
|
326
326
|
} else {
|
|
327
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
528
|
+
await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${CodexAdapter._redact(e.message)}`);
|
|
475
529
|
}
|
|
476
530
|
}
|
|
477
531
|
|
package/src/adapters/cursor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/adapters/openclaw.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/adapters/opencode.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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 ${
|
|
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 };
|