@openagents-org/agent-launcher 0.2.178 → 0.2.180
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/base.js +108 -1
- package/src/adapters/opencode.js +82 -20
- package/src/adapters/workspace-prompt.js +22 -2
- package/src/paths.js +178 -24
package/package.json
CHANGED
package/src/adapters/base.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
|
|
21
21
|
const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
|
|
22
22
|
const { defaultAgentWorkdir } = require('../paths');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
23
26
|
const {
|
|
24
27
|
decisionLogTitle,
|
|
25
28
|
glossaryTitle,
|
|
@@ -35,6 +38,21 @@ const {
|
|
|
35
38
|
|
|
36
39
|
const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
|
|
37
40
|
|
|
41
|
+
// ── Poll-cursor persistence ──
|
|
42
|
+
// An adapter that starts by jumping to the head of the event stream silently
|
|
43
|
+
// drops every message that arrived while it was down (crash, update, user
|
|
44
|
+
// bounce). Users see that as "I said 继续 and the agent never answered". The
|
|
45
|
+
// cursor is therefore persisted after every poll and resumed on start.
|
|
46
|
+
//
|
|
47
|
+
// Replay is bounded: on the first poll after a resume, messages older than
|
|
48
|
+
// STALE_MESSAGE_MAX_AGE_MS are skipped (and the channel told), so a launcher
|
|
49
|
+
// that was off for a day does not act on hours-old instructions unannounced.
|
|
50
|
+
// This also covers a backend that ignores an unknown/deleted cursor and
|
|
51
|
+
// returns history from the start. A persisted cursor older than
|
|
52
|
+
// CURSOR_RESUME_MAX_AGE_MS is ignored altogether (jump to head as before).
|
|
53
|
+
const STALE_MESSAGE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
54
|
+
const CURSOR_RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
55
|
+
|
|
38
56
|
// Heartbeat runs every 30s. A SINGLE failure is usually a transient blip (brief
|
|
39
57
|
// network hiccup, server redeploy) that the next tick recovers from — surfacing
|
|
40
58
|
// it as a hard 'error' would make the agent flap red for no real reason. Only
|
|
@@ -77,6 +95,9 @@ class BaseAdapter {
|
|
|
77
95
|
this._stopRequested = false;
|
|
78
96
|
this.client = new WorkspaceClient(this.endpoint);
|
|
79
97
|
this._lastEventId = null;
|
|
98
|
+
this._cursorFile = this._cursorFilePath();
|
|
99
|
+
this._lastPersistedCursor = null;
|
|
100
|
+
this._resumedFromCursor = false;
|
|
80
101
|
this._running = false;
|
|
81
102
|
this._sessionId = null; // issued by server on /v1/join; used to prove liveness
|
|
82
103
|
this._processedIds = new Set();
|
|
@@ -263,7 +284,64 @@ class BaseAdapter {
|
|
|
263
284
|
// Event cursor / skip existing
|
|
264
285
|
// ------------------------------------------------------------------
|
|
265
286
|
|
|
287
|
+
/** Where this agent's poll cursor lives between runs (per workspace+agent). */
|
|
288
|
+
_cursorFilePath() {
|
|
289
|
+
const safe = (v) => String(v || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
290
|
+
return path.join(os.homedir(), '.openagents', 'cursors', `${safe(this.workspaceId)}__${safe(this.agentName)}.json`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The cursor saved by the previous run, or null when absent/stale/foreign. */
|
|
294
|
+
_loadPersistedCursor() {
|
|
295
|
+
try {
|
|
296
|
+
const raw = JSON.parse(fs.readFileSync(this._cursorFile, 'utf-8'));
|
|
297
|
+
if (!raw || typeof raw.cursor !== 'string' || !raw.cursor) return null;
|
|
298
|
+
if (raw.workspaceId !== this.workspaceId || raw.agentName !== this.agentName) return null;
|
|
299
|
+
const age = Date.now() - (Number(raw.savedAt) || 0);
|
|
300
|
+
if (!(age >= 0 && age <= CURSOR_RESUME_MAX_AGE_MS)) return null;
|
|
301
|
+
return raw.cursor;
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Persist the poll cursor (no-op when unchanged; never throws). */
|
|
308
|
+
_persistCursor(cursor) {
|
|
309
|
+
if (!cursor || cursor === this._lastPersistedCursor) return;
|
|
310
|
+
this._lastPersistedCursor = cursor;
|
|
311
|
+
try {
|
|
312
|
+
fs.mkdirSync(path.dirname(this._cursorFile), { recursive: true });
|
|
313
|
+
const tmp = `${this._cursorFile}.tmp`;
|
|
314
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
315
|
+
cursor, savedAt: Date.now(), workspaceId: this.workspaceId, agentName: this.agentName,
|
|
316
|
+
}));
|
|
317
|
+
fs.renameSync(tmp, this._cursorFile);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (!this._warnedCursorPersist) {
|
|
320
|
+
this._warnedCursorPersist = true;
|
|
321
|
+
this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** True when a replayed message is too old to act on (see STALE_MESSAGE_MAX_AGE_MS). */
|
|
327
|
+
_isStaleMessage(msg) {
|
|
328
|
+
if (!msg || !msg.createdAt) return false;
|
|
329
|
+
const t = Date.parse(msg.createdAt);
|
|
330
|
+
if (!Number.isFinite(t)) return false;
|
|
331
|
+
return Date.now() - t > STALE_MESSAGE_MAX_AGE_MS;
|
|
332
|
+
}
|
|
333
|
+
|
|
266
334
|
async _skipExistingEvents() {
|
|
335
|
+
// Prefer the cursor the previous run persisted: a restart must not drop the
|
|
336
|
+
// messages that arrived in between (bounded by the stale filter in the
|
|
337
|
+
// poll loop).
|
|
338
|
+
const persisted = this._loadPersistedCursor();
|
|
339
|
+
if (persisted) {
|
|
340
|
+
this._lastEventId = persisted;
|
|
341
|
+
this._resumedFromCursor = true;
|
|
342
|
+
this._log(`Resuming from persisted cursor ${persisted}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
267
345
|
// Jump straight to the head with one server call. Pagination from the
|
|
268
346
|
// start was slow and brittle: on a busy workspace it could take many
|
|
269
347
|
// minutes to chew through historical events 200 at a time, leaving the
|
|
@@ -272,6 +350,7 @@ class BaseAdapter {
|
|
|
272
350
|
const head = await this.client.getHeadEventId(this.workspaceId, this.token);
|
|
273
351
|
if (head) {
|
|
274
352
|
this._lastEventId = head;
|
|
353
|
+
this._persistCursor(head);
|
|
275
354
|
this._log(`Skipped existing events, cursor at ${head}`);
|
|
276
355
|
}
|
|
277
356
|
}
|
|
@@ -689,7 +768,16 @@ class BaseAdapter {
|
|
|
689
768
|
continue;
|
|
690
769
|
}
|
|
691
770
|
|
|
692
|
-
if (rawCursor)
|
|
771
|
+
if (rawCursor) {
|
|
772
|
+
this._lastEventId = rawCursor;
|
|
773
|
+
this._persistCursor(rawCursor);
|
|
774
|
+
}
|
|
775
|
+
// Only the first poll after resuming from a persisted cursor is a
|
|
776
|
+
// replay; later polls carry live traffic and are never age-filtered
|
|
777
|
+
// (a skewed local clock must not make a live agent go deaf).
|
|
778
|
+
const isReplay = this._resumedFromCursor;
|
|
779
|
+
this._resumedFromCursor = false;
|
|
780
|
+
const staleByChannel = {};
|
|
693
781
|
|
|
694
782
|
// Deduplicate
|
|
695
783
|
const incoming = [];
|
|
@@ -697,6 +785,12 @@ class BaseAdapter {
|
|
|
697
785
|
const msgId = msg.id || msg.messageId;
|
|
698
786
|
if (msgId && this._processedIds.has(msgId)) continue;
|
|
699
787
|
if (msg.messageType === 'status') continue;
|
|
788
|
+
if (isReplay && this._isStaleMessage(msg)) {
|
|
789
|
+
if (msgId) this._processedIds.add(msgId);
|
|
790
|
+
const ch = msg.sessionId || this.channelName || 'general';
|
|
791
|
+
staleByChannel[ch] = (staleByChannel[ch] || 0) + 1;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
700
794
|
// Handle queue cancellation signals from frontend
|
|
701
795
|
if (msg.messageType === 'queue_cancel') {
|
|
702
796
|
if (msgId) this._processedIds.add(msgId);
|
|
@@ -708,6 +802,17 @@ class BaseAdapter {
|
|
|
708
802
|
incoming.push(msg);
|
|
709
803
|
}
|
|
710
804
|
|
|
805
|
+
for (const [ch, n] of Object.entries(staleByChannel)) {
|
|
806
|
+
const mins = Math.round(STALE_MESSAGE_MAX_AGE_MS / 60000);
|
|
807
|
+
this._log(`Skipped ${n} stale message(s) in ${ch} (older than ${mins} min, arrived while this agent was offline)`);
|
|
808
|
+
try {
|
|
809
|
+
await this.sendStatus(ch, `Skipped ${n} message(s) sent more than ${mins} minutes ago while this agent was offline — resend if still needed.`);
|
|
810
|
+
} catch {}
|
|
811
|
+
}
|
|
812
|
+
if (isReplay && incoming.length > 0) {
|
|
813
|
+
this._log(`Replaying ${incoming.length} message(s) that arrived while this agent was offline`);
|
|
814
|
+
}
|
|
815
|
+
|
|
711
816
|
if (incoming.length > 0) {
|
|
712
817
|
idleCount = 0;
|
|
713
818
|
for (const msg of incoming) {
|
|
@@ -1231,3 +1336,5 @@ class BaseAdapter {
|
|
|
1231
1336
|
}
|
|
1232
1337
|
|
|
1233
1338
|
module.exports = BaseAdapter;
|
|
1339
|
+
module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
|
|
1340
|
+
module.exports.CURSOR_RESUME_MAX_AGE_MS = CURSOR_RESUME_MAX_AGE_MS;
|
package/src/adapters/opencode.js
CHANGED
|
@@ -74,6 +74,7 @@ const FAILURE_MESSAGES = {
|
|
|
74
74
|
timeout: 'OpenCode timed out before producing a reply. Retry; if it persists, check the model and provider configuration.',
|
|
75
75
|
stream_parse_error: 'OpenCode produced output this version could not parse. Update to a supported OpenCode version, or open diagnostics.',
|
|
76
76
|
empty_response: 'OpenCode finished without producing a final reply. Retry; if it persists, open diagnostics.',
|
|
77
|
+
incomplete_run: 'OpenCode stopped mid-task right after a tool call and never produced a final reply — its progress notes are shown above. Send "continue" to pick up where it left off. If this keeps happening, that tool call itself is failing (on Windows, prefer single-line commands over multi-line PowerShell).',
|
|
77
78
|
process_crashed: 'OpenCode exited unexpectedly. Open diagnostics or retry.',
|
|
78
79
|
cwd_unavailable: "OpenCode's working directory is not accessible. Check the agent's configured path (or agent home) permissions.",
|
|
79
80
|
unknown_error: 'OpenCode failed for an undetermined reason. Open diagnostics or retry.',
|
|
@@ -449,6 +450,13 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
449
450
|
return text || null;
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/** Tool name from a tool event (null for non-tool events). */
|
|
454
|
+
static _toolNameFromEvent(event) {
|
|
455
|
+
if (!OpenCodeAdapter._isToolEvent(event)) return null;
|
|
456
|
+
const item = event.item || event.part || event.tool || event;
|
|
457
|
+
return OpenCodeAdapter._safeToolName(item.name || item.tool || item.toolName || item.id || 'tool');
|
|
458
|
+
}
|
|
459
|
+
|
|
452
460
|
static _toolStatusFromEvent(event) {
|
|
453
461
|
if (!OpenCodeAdapter._isToolEvent(event)) return null;
|
|
454
462
|
|
|
@@ -502,11 +510,13 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
502
510
|
const status = OpenCodeAdapter._toolStatusFromEvent(event);
|
|
503
511
|
if (status) {
|
|
504
512
|
// A tool call resets `finalText` (the "text since the last tool"), so the
|
|
505
|
-
// final answer is the text emitted AFTER the last tool.
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
513
|
+
// final answer is the text emitted AFTER the last tool. `allText` stays
|
|
514
|
+
// intact for diagnostics, and `lastTool` lets the exit handler tell a
|
|
515
|
+
// finished answer from a run that died on its last tool call.
|
|
516
|
+
if (responseState) {
|
|
517
|
+
responseState.finalText = '';
|
|
518
|
+
responseState.lastTool = OpenCodeAdapter._toolNameFromEvent(event);
|
|
519
|
+
}
|
|
510
520
|
await this.sendStatus(msgChannel, status);
|
|
511
521
|
return;
|
|
512
522
|
}
|
|
@@ -906,24 +916,75 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
906
916
|
|
|
907
917
|
// Exit 0.
|
|
908
918
|
if (stdout) this._persistSessionId(msgChannel, stdout);
|
|
909
|
-
const
|
|
910
|
-
if (text) return finish(resolve, text);
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
// stream, or a genuinely empty completion. Classify rather than guess.
|
|
915
|
-
if (stdoutErr) {
|
|
916
|
-
const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
|
|
917
|
-
this._log(`opencode exit 0 with error event → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
|
|
918
|
-
return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
|
|
919
|
-
}
|
|
920
|
-
const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
|
|
921
|
-
this._log(`opencode exit 0, no final text → [${emptyCat}]. stdout head: ${OpenCodeAdapter._redact(stdout).slice(0, 200)}`);
|
|
922
|
-
return finish(reject, this._failure(emptyCat, `exit 0, no final assistant text; stdout head: ${stdout.slice(0, 200)}`));
|
|
919
|
+
const outcome = OpenCodeAdapter._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
|
|
920
|
+
if (outcome.text) return finish(resolve, outcome.text);
|
|
921
|
+
const f = outcome.failure;
|
|
922
|
+
this._log(`opencode exit 0 → [${f.category}]: ${OpenCodeAdapter._redact(f.diagnostic).slice(0, 300)}`);
|
|
923
|
+
return finish(reject, this._failure(f.category, f.diagnostic, f.detail));
|
|
923
924
|
});
|
|
924
925
|
});
|
|
925
926
|
}
|
|
926
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Decide what an exit-0 run means. Returns `{ text }` for a real reply or
|
|
930
|
+
* `{ failure: { category, diagnostic, detail } }`.
|
|
931
|
+
*
|
|
932
|
+
* A run that ends on a tool call with no text after it is NOT a finished
|
|
933
|
+
* answer: opencode stopped right as (or because) that tool ran. Field
|
|
934
|
+
* reports (Windows, multi-line PowerShell through the `bash` tool) showed
|
|
935
|
+
* the streamed reasoning being posted as the reply, which reads as the agent
|
|
936
|
+
* "going silent mid-task". Report it as a failure instead — preferring the
|
|
937
|
+
* structured error event when opencode emitted one — so the user gets a
|
|
938
|
+
* clear "stopped after `bash`" plus a way forward. Exit 0 with no text at
|
|
939
|
+
* all is likewise classified rather than guessed at.
|
|
940
|
+
*/
|
|
941
|
+
static _outcomeForCleanExit({ stdout = '', stderr = '', stdoutErr = null, responseState = null } = {}) {
|
|
942
|
+
stdout = String(stdout || '').trim();
|
|
943
|
+
const state = responseState || {};
|
|
944
|
+
const lastTool = state.lastTool || null;
|
|
945
|
+
const endedOnTool = !!lastTool && !(state.finalText || '').trim();
|
|
946
|
+
|
|
947
|
+
if (endedOnTool) {
|
|
948
|
+
if (stdoutErr) {
|
|
949
|
+
const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
|
|
950
|
+
const vague = ['unknown_error', 'process_crashed', 'empty_response'].includes(cls.category);
|
|
951
|
+
return {
|
|
952
|
+
failure: {
|
|
953
|
+
category: vague ? 'incomplete_run' : cls.category,
|
|
954
|
+
diagnostic: `exit 0 after tool ${lastTool} with error event: ${cls.diagnostic || ''}`,
|
|
955
|
+
detail: cls.detail || `Last tool call: \`${lastTool}\``,
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
failure: {
|
|
961
|
+
category: 'incomplete_run',
|
|
962
|
+
diagnostic: `exit 0 right after tool ${lastTool}, no final assistant text`,
|
|
963
|
+
detail: `Last tool call: \`${lastTool}\``,
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const text = stdout ? OpenCodeAdapter._finalTextFromStdout(stdout, responseState) : '';
|
|
969
|
+
if (text) return { text };
|
|
970
|
+
|
|
971
|
+
// Exit 0 with no final assistant text. This is NOT proof of a missing
|
|
972
|
+
// provider — it can be a structured error on stdout, an incomplete
|
|
973
|
+
// stream, or a genuinely empty completion. Classify rather than guess.
|
|
974
|
+
if (stdoutErr) {
|
|
975
|
+
const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
|
|
976
|
+
return { failure: { category: cls.category, diagnostic: `exit 0 with error event: ${cls.diagnostic || ''}`, detail: cls.detail } };
|
|
977
|
+
}
|
|
978
|
+
const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
|
|
979
|
+
return {
|
|
980
|
+
failure: {
|
|
981
|
+
category: emptyCat,
|
|
982
|
+
diagnostic: `exit 0, no final assistant text; stdout head: ${stdout.slice(0, 200)}`,
|
|
983
|
+
detail: '',
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
927
988
|
// ------------------------------------------------------------------
|
|
928
989
|
// Preflight (deterministic, no model call) + failure classification
|
|
929
990
|
// ------------------------------------------------------------------
|
|
@@ -1069,7 +1130,8 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
1069
1130
|
const base = FAILURE_MESSAGES[category] || FAILURE_MESSAGES.unknown_error;
|
|
1070
1131
|
const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
|
|
1071
1132
|
const body = safe ? `${base}\n\n> ${safe}` : base;
|
|
1072
|
-
const
|
|
1133
|
+
const headline = category === 'incomplete_run' ? 'OpenCode stopped mid-task' : "OpenCode couldn't run";
|
|
1134
|
+
const content = `⚠️ **${headline}** — ${body}`;
|
|
1073
1135
|
try {
|
|
1074
1136
|
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
1075
1137
|
senderType: 'agent',
|
|
@@ -178,6 +178,25 @@ function buildCollaborationPrompt(toolMode = 'mcp', skillName = 'openagents-work
|
|
|
178
178
|
);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Windows-only shell guidance for CLIs whose shell tool is named `bash`.
|
|
183
|
+
* Field reports (2026-09) show OpenCode runs on Windows ending the moment the
|
|
184
|
+
* model sends multi-line PowerShell (here-strings, hash-table literals,
|
|
185
|
+
* `$var = ...` blocks) through that tool: the turn stops with no reply and no
|
|
186
|
+
* error. Steering the model to single-line commands and the file tools avoids
|
|
187
|
+
* the failure at the source.
|
|
188
|
+
*/
|
|
189
|
+
function buildWindowsShellHint(isWindows = process.platform === 'win32') {
|
|
190
|
+
if (!isWindows) return '';
|
|
191
|
+
return (
|
|
192
|
+
'\n## Shell on Windows\n' +
|
|
193
|
+
'This machine runs Windows. When you run shell commands:\n' +
|
|
194
|
+
'- Keep every command on ONE line. Never send multi-line scripts, PowerShell here-strings (@"..."@), hash-table literals (@{...}) or `$var = ...` blocks through the shell tool — the run will abort.\n' +
|
|
195
|
+
'- To create or change files with multi-line content, use the file write/edit tools, not shell redirection or here-strings.\n' +
|
|
196
|
+
'- Prefer simple one-line commands (`dir`, `type`, `findstr`, `curl.exe`, version-control commands), or one short `powershell -Command "..."` per call.\n'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
181
200
|
/**
|
|
182
201
|
* Build mode-specific instructions.
|
|
183
202
|
*/
|
|
@@ -990,11 +1009,11 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel
|
|
|
990
1009
|
* `decisionLog` / `glossary` opt in to knowledge pinning exactly as in
|
|
991
1010
|
* buildOpenclawSystemPrompt.
|
|
992
1011
|
*/
|
|
993
|
-
function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null }) {
|
|
1012
|
+
function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null, isWindows = process.platform === 'win32' }) {
|
|
994
1013
|
const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
|
|
995
1014
|
const directive = buildBrowserDirective(browserEnabled);
|
|
996
1015
|
const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
|
|
997
|
-
const modePrompt = buildModePrompt(mode);
|
|
1016
|
+
const modePrompt = buildModePrompt(mode) + buildWindowsShellHint(isWindows);
|
|
998
1017
|
const pinned = buildPinnedSections({ toolMode: 'skills', channelName, mode, decisionLog, glossary })
|
|
999
1018
|
.map((s) => '\n' + s).join('');
|
|
1000
1019
|
const api = buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode });
|
|
@@ -1241,6 +1260,7 @@ module.exports = {
|
|
|
1241
1260
|
buildBrowserDirective,
|
|
1242
1261
|
buildCollaborationPrompt,
|
|
1243
1262
|
buildModePrompt,
|
|
1263
|
+
buildWindowsShellHint,
|
|
1244
1264
|
buildGuardrails,
|
|
1245
1265
|
buildApiSkillsPrompt,
|
|
1246
1266
|
buildClaudeMcpToolBlock,
|
package/src/paths.js
CHANGED
|
@@ -19,21 +19,27 @@ const SEP = IS_WINDOWS ? ';' : ':';
|
|
|
19
19
|
const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
|
20
20
|
const PATH_LOOKUP_CACHE_TTL_MS = 30 * 1000;
|
|
21
21
|
|
|
22
|
+
let knownBinDirsCache = { value: null, at: 0 };
|
|
22
23
|
let extraBinDirsCache = { value: null, at: 0, path: '' };
|
|
23
24
|
const whichBinaryCache = new Map();
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* Every directory an agent CLI is known to land in, filtered to the ones that
|
|
28
|
+
* exist on this machine. Ranked: a copy the launcher installed outranks a copy
|
|
29
|
+
* the user installed, which outranks whatever the login shell happens to add.
|
|
30
|
+
*
|
|
31
|
+
* This is the whole curated set, INCLUDING dirs that are already on PATH —
|
|
32
|
+
* which is what separates it from getExtraBinDirs(). Anything resolving a
|
|
33
|
+
* binary off the filesystem wants this one: that lookup exists precisely for
|
|
34
|
+
* the cases where asking the shell failed, and a dir being on PATH is no
|
|
35
|
+
* evidence at all that `where`/`which` succeeded (see resolveBinaryInKnownDirs).
|
|
28
36
|
*/
|
|
29
|
-
function
|
|
30
|
-
const currentPATH = process.env.PATH || '';
|
|
37
|
+
function getKnownBinDirs() {
|
|
31
38
|
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Date.now() - extraBinDirsCache.at < PATH_LOOKUP_CACHE_TTL_MS
|
|
39
|
+
knownBinDirsCache.value &&
|
|
40
|
+
Date.now() - knownBinDirsCache.at < PATH_LOOKUP_CACHE_TTL_MS
|
|
35
41
|
) {
|
|
36
|
-
return [...
|
|
42
|
+
return [...knownBinDirsCache.value];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
const dirs = [];
|
|
@@ -99,8 +105,6 @@ function getExtraBinDirs() {
|
|
|
99
105
|
const seen = new Set();
|
|
100
106
|
const value = dirs.filter(d => {
|
|
101
107
|
if (!d || seen.has(d)) return false;
|
|
102
|
-
// Skip if already in PATH (case-insensitive on Windows)
|
|
103
|
-
if (IS_WINDOWS ? currentPATH.toLowerCase().includes(d.toLowerCase()) : currentPATH.includes(d)) return false;
|
|
104
108
|
seen.add(d);
|
|
105
109
|
try {
|
|
106
110
|
return fs.statSync(d).isDirectory();
|
|
@@ -108,6 +112,32 @@ function getExtraBinDirs() {
|
|
|
108
112
|
return false;
|
|
109
113
|
}
|
|
110
114
|
});
|
|
115
|
+
knownBinDirsCache = { value, at: Date.now() };
|
|
116
|
+
return [...value];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The known bin dirs that are NOT already on PATH — i.e. what has to be
|
|
121
|
+
* PREPENDED to build an environment in which every agent CLI resolves.
|
|
122
|
+
*
|
|
123
|
+
* Only ever use this to build a PATH. It is a strict subset of
|
|
124
|
+
* getKnownBinDirs(), and the dirs it drops (%APPDATA%\npm, /usr/local/bin, …)
|
|
125
|
+
* are the most common install locations there are, so using it as "the dirs to
|
|
126
|
+
* search" hides exactly the agents a user installed themselves.
|
|
127
|
+
*/
|
|
128
|
+
function getExtraBinDirs() {
|
|
129
|
+
const currentPATH = process.env.PATH || '';
|
|
130
|
+
if (
|
|
131
|
+
extraBinDirsCache.value &&
|
|
132
|
+
extraBinDirsCache.path === currentPATH &&
|
|
133
|
+
Date.now() - extraBinDirsCache.at < PATH_LOOKUP_CACHE_TTL_MS
|
|
134
|
+
) {
|
|
135
|
+
return [...extraBinDirsCache.value];
|
|
136
|
+
}
|
|
137
|
+
const value = getKnownBinDirs().filter((d) =>
|
|
138
|
+
// Case-insensitive on Windows.
|
|
139
|
+
IS_WINDOWS ? !currentPATH.toLowerCase().includes(d.toLowerCase()) : !currentPATH.includes(d),
|
|
140
|
+
);
|
|
111
141
|
extraBinDirsCache = { value, at: Date.now(), path: currentPATH };
|
|
112
142
|
return [...value];
|
|
113
143
|
}
|
|
@@ -176,7 +206,13 @@ function whichBinary(name) {
|
|
|
176
206
|
const result = execSync(cmd, {
|
|
177
207
|
encoding: 'utf-8',
|
|
178
208
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
179
|
-
|
|
209
|
+
// getEnhancedEnv(), never `{...process.env, PATH}`: spreading process.env
|
|
210
|
+
// on Windows yields a "Path" key, so assigning PATH creates a SECOND path
|
|
211
|
+
// variable and which of the two the child reads is undefined. It also
|
|
212
|
+
// supplies ComSpec, without which execSync's shell cannot even start in an
|
|
213
|
+
// Electron process that has no cmd.exe on PATH — and a `where` that never
|
|
214
|
+
// ran looks exactly like a CLI that isn't installed.
|
|
215
|
+
env: getEnhancedEnv(),
|
|
180
216
|
timeout: 5000,
|
|
181
217
|
windowsHide: true,
|
|
182
218
|
});
|
|
@@ -335,10 +371,11 @@ function _addPackageManagerPaths(dirs) {
|
|
|
335
371
|
* reasons; anything added here is picked up on both.
|
|
336
372
|
*/
|
|
337
373
|
function _addAgentInstallerPaths(dirs) {
|
|
338
|
-
// opencode — `curl -fsSL https://opencode.ai/install | bash`
|
|
339
|
-
//
|
|
340
|
-
// "launcher can't see my opencode"
|
|
341
|
-
|
|
374
|
+
// opencode — `curl -fsSL https://opencode.ai/install | bash` is the route
|
|
375
|
+
// opencode.ai leads with, and its INSTALL_DIR is ~/.opencode/bin, reached
|
|
376
|
+
// through a shell rc edit. Reported as "launcher can't see my opencode"
|
|
377
|
+
// (#648, and again on launcher 0.9.27).
|
|
378
|
+
_push(dirs, path.join(HOME, '.opencode', 'bin'));
|
|
342
379
|
|
|
343
380
|
// kimi — the npm package (@moonshot-ai/kimi-code) declares a real `kimi` bin,
|
|
344
381
|
// but its postinstall ALSO drops a native build in ~/.kimi-code/bin and puts
|
|
@@ -354,6 +391,61 @@ function _addAgentInstallerPaths(dirs) {
|
|
|
354
391
|
_push(dirs, path.join(HOME, '.npm-global', 'bin'));
|
|
355
392
|
_push(dirs, path.join(HOME, '.openagents', 'npm-global', 'bin'));
|
|
356
393
|
|
|
394
|
+
// codebuddy — the npm package drops the usual global shim, but CodeBuddy also
|
|
395
|
+
// ships a native build (the engine behind the WorkBuddy desktop app) that
|
|
396
|
+
// installs to its own directory. The codebuddy adapter has always searched
|
|
397
|
+
// these; the installer that decides whether it EXISTS had not.
|
|
398
|
+
_push(dirs, path.join(HOME, '.codebuddy', 'bin'));
|
|
399
|
+
|
|
400
|
+
// claude — `claude install` (the native, non-npm build) relocates the CLI to
|
|
401
|
+
// ~/.claude/local and reaches it through a shell alias, which a GUI process
|
|
402
|
+
// inherits no more than it inherits an rc-file PATH edit.
|
|
403
|
+
_push(dirs, path.join(HOME, '.claude', 'local'));
|
|
404
|
+
|
|
405
|
+
// hermes — the Unix installer's own bin dir. Its Windows counterparts are in
|
|
406
|
+
// _addWindowsPaths; this is the half nothing covered.
|
|
407
|
+
_push(dirs, path.join(HOME, '.hermes', 'bin'));
|
|
408
|
+
|
|
409
|
+
// Installers that let the user choose the install dir. Each of these CLIs
|
|
410
|
+
// reads an env var for its target, and a user who set one has their ONLY copy
|
|
411
|
+
// there — every hardcoded default above then finds nothing. Added rather than
|
|
412
|
+
// substituted: the default dir usually still holds an older copy, and which
|
|
413
|
+
// of the two is on the user's PATH is not ours to guess.
|
|
414
|
+
if (process.env.OPENCODE_INSTALL_DIR) _push(dirs, process.env.OPENCODE_INSTALL_DIR);
|
|
415
|
+
if (process.env.AMP_HOME) _push(dirs, path.join(process.env.AMP_HOME, 'bin'));
|
|
416
|
+
if (process.env.GOOSE_BIN_DIR) _push(dirs, process.env.GOOSE_BIN_DIR);
|
|
417
|
+
if (process.env.HERMES_INSTALL_DIR) _push(dirs, process.env.HERMES_INSTALL_DIR);
|
|
418
|
+
if (process.env.HERMES_HOME) _push(dirs, path.join(process.env.HERMES_HOME, 'bin'));
|
|
419
|
+
|
|
420
|
+
// uv — `uv tool install X` builds a venv at <uv tool dir>/X and only COPIES
|
|
421
|
+
// the executable into uv's bin dir, which reaches PATH through a shell rc
|
|
422
|
+
// edit a GUI launch never sees. Enumerated rather than named, for the same
|
|
423
|
+
// reason as pipx below: aider, mini-swe-agent and openworker all arrive this
|
|
424
|
+
// way today — `uv tool install mini-swe-agent` is the route mini's own docs
|
|
425
|
+
// give, and the mini adapter already searched here while the detection that
|
|
426
|
+
// decides whether mini EXISTS did not — and so will the next Python agent.
|
|
427
|
+
for (const root of _uvToolRoots()) {
|
|
428
|
+
try {
|
|
429
|
+
for (const d of fs.readdirSync(root, { withFileTypes: true })) {
|
|
430
|
+
if (d.isDirectory()) _push(dirs, path.join(root, d.name, IS_WINDOWS ? 'Scripts' : 'bin'));
|
|
431
|
+
}
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// pipx — `pipx install X` puts the executable in <PIPX_HOME>/venvs/X/bin and
|
|
436
|
+
// only SYMLINKS it into PIPX_BIN_DIR. When that link step is skipped, or its
|
|
437
|
+
// dir is one a GUI launch can't see, the venv copy is the only one there is.
|
|
438
|
+
// Enumerated rather than listed by name: aider, mini-swe-agent and openworker
|
|
439
|
+
// are all installable this way, and so is the next Python agent.
|
|
440
|
+
for (const root of _pipxHomes()) {
|
|
441
|
+
try {
|
|
442
|
+
const venvs = path.join(root, 'venvs');
|
|
443
|
+
for (const d of fs.readdirSync(venvs, { withFileTypes: true })) {
|
|
444
|
+
if (d.isDirectory()) _push(dirs, path.join(venvs, d.name, IS_WINDOWS ? 'Scripts' : 'bin'));
|
|
445
|
+
}
|
|
446
|
+
} catch {}
|
|
447
|
+
}
|
|
448
|
+
|
|
357
449
|
// The plain ~/bin some installers fall back to when ~/.local/bin is absent.
|
|
358
450
|
_push(dirs, path.join(HOME, 'bin'));
|
|
359
451
|
|
|
@@ -365,7 +457,50 @@ function _addAgentInstallerPaths(dirs) {
|
|
|
365
457
|
// cursor-agent also ships under Programs\ on some Windows installs — the
|
|
366
458
|
// cursor adapter checks both, the installer only knew one.
|
|
367
459
|
_push(dirs, path.join(lad, 'Programs', 'cursor-agent'));
|
|
460
|
+
// codebuddy's native Windows install.
|
|
461
|
+
_push(dirs, path.join(lad, 'CodeBuddy', 'bin'));
|
|
462
|
+
// winget's shim dir — how GitHub Copilot CLI arrives on Windows for anyone
|
|
463
|
+
// who didn't take the npm route. The copilot adapter knew it; nothing else did.
|
|
464
|
+
_push(dirs, path.join(lad, 'Microsoft', 'WinGet', 'Links'));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Roots uv keeps its tool venvs under. UV_TOOL_DIR wins when exported;
|
|
470
|
+
* otherwise it is the XDG data dir on Unix — macOS included, uv does NOT use
|
|
471
|
+
* ~/Library/Application Support for this — and %APPDATA%\uv\tools on Windows.
|
|
472
|
+
*/
|
|
473
|
+
function _uvToolRoots() {
|
|
474
|
+
if (process.env.UV_TOOL_DIR) return [process.env.UV_TOOL_DIR];
|
|
475
|
+
// os.homedir(), not the module-level HOME: uvToolBinDirs() is also called by
|
|
476
|
+
// the adapters and the post-install verifier at arbitrary times, and that
|
|
477
|
+
// constant is frozen at require time — which resolved to the developer's real
|
|
478
|
+
// home in a test that had moved $HOME.
|
|
479
|
+
const home = os.homedir();
|
|
480
|
+
if (IS_WINDOWS) {
|
|
481
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
482
|
+
return [path.join(appData, 'uv', 'tools')];
|
|
368
483
|
}
|
|
484
|
+
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
485
|
+
return [path.join(dataHome, 'uv', 'tools')];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Roots pipx may keep its venvs under. PIPX_HOME wins when exported; otherwise
|
|
490
|
+
* pipx has moved its default once (~/.local/pipx → the platform user-data dir),
|
|
491
|
+
* so both layouts are still in the field.
|
|
492
|
+
*/
|
|
493
|
+
function _pipxHomes() {
|
|
494
|
+
if (process.env.PIPX_HOME) return [process.env.PIPX_HOME];
|
|
495
|
+
const roots = [path.join(HOME, '.local', 'pipx')];
|
|
496
|
+
if (IS_WINDOWS) {
|
|
497
|
+
if (process.env.LOCALAPPDATA) roots.push(path.join(process.env.LOCALAPPDATA, 'pipx', 'pipx'));
|
|
498
|
+
} else if (IS_MACOS) {
|
|
499
|
+
roots.push(path.join(HOME, 'Library', 'Application Support', 'pipx'));
|
|
500
|
+
} else {
|
|
501
|
+
roots.push(path.join(process.env.XDG_DATA_HOME || path.join(HOME, '.local', 'share'), 'pipx'));
|
|
502
|
+
}
|
|
503
|
+
return roots;
|
|
369
504
|
}
|
|
370
505
|
|
|
371
506
|
let npmPrefixCache;
|
|
@@ -594,6 +729,17 @@ function _addUnixPaths(dirs) {
|
|
|
594
729
|
_push(dirs, '/usr/local/bin');
|
|
595
730
|
_push(dirs, '/usr/bin');
|
|
596
731
|
|
|
732
|
+
// Homebrew on Linux. macOS gets /opt/homebrew and /usr/local from
|
|
733
|
+
// _addMacPaths; Linuxbrew's prefix is in neither list, and `brew install` is
|
|
734
|
+
// a real route for goose, opencode, gemini and codex. Its PATH entry comes
|
|
735
|
+
// from `brew shellenv` in a shell rc file — invisible to a GUI launch, the
|
|
736
|
+
// same reason every other dir here is listed explicitly.
|
|
737
|
+
if (!IS_MACOS) {
|
|
738
|
+
if (process.env.HOMEBREW_PREFIX) _push(dirs, path.join(process.env.HOMEBREW_PREFIX, 'bin'));
|
|
739
|
+
_push(dirs, '/home/linuxbrew/.linuxbrew/bin');
|
|
740
|
+
_push(dirs, path.join(HOME, '.linuxbrew', 'bin'));
|
|
741
|
+
}
|
|
742
|
+
|
|
597
743
|
// npm agents install to isolated prefixes: ~/.openagents/runtimes/<type>/
|
|
598
744
|
|
|
599
745
|
// nvm
|
|
@@ -767,6 +913,7 @@ function defaultAgentWorkdir(agentName) {
|
|
|
767
913
|
* `whichBinary(name) === null` cached before install doesn't survive.
|
|
768
914
|
*/
|
|
769
915
|
function clearBinaryLookupCache() {
|
|
916
|
+
knownBinDirsCache = { value: null, at: 0 };
|
|
770
917
|
extraBinDirsCache = { value: null, at: 0, path: '' };
|
|
771
918
|
whichBinaryCache.clear();
|
|
772
919
|
// The login-shell / npm-prefix probes are deliberately NOT cleared: they each
|
|
@@ -785,7 +932,7 @@ function clearBinaryLookupCache() {
|
|
|
785
932
|
function primeBinaryLookup() {
|
|
786
933
|
try { loginShellDirs(); } catch {}
|
|
787
934
|
try { _npmPrefix(); } catch {}
|
|
788
|
-
try {
|
|
935
|
+
try { getKnownBinDirs(); } catch {}
|
|
789
936
|
}
|
|
790
937
|
|
|
791
938
|
/**
|
|
@@ -811,15 +958,12 @@ function uvToolBinDirs(pkg) {
|
|
|
811
958
|
if (process.env.XDG_BIN_HOME) dirs.push(process.env.XDG_BIN_HOME);
|
|
812
959
|
if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, '..', 'bin'));
|
|
813
960
|
dirs.push(path.join(home, '.local', 'bin'));
|
|
961
|
+
for (const root of _uvToolRoots()) {
|
|
962
|
+
dirs.push(path.join(root, pkg, IS_WINDOWS ? 'Scripts' : 'bin'));
|
|
963
|
+
}
|
|
814
964
|
if (IS_WINDOWS) {
|
|
815
|
-
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
816
|
-
const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
|
|
817
|
-
dirs.push(path.join(uvTools, pkg, 'Scripts'));
|
|
818
965
|
dirs.push(path.join(home, 'bin'));
|
|
819
966
|
} else {
|
|
820
|
-
const uvTools = process.env.UV_TOOL_DIR
|
|
821
|
-
|| path.join(home, '.local', 'share', 'uv', 'tools');
|
|
822
|
-
dirs.push(path.join(uvTools, pkg, 'bin'));
|
|
823
967
|
dirs.push(path.join(home, 'bin'), '/usr/local/bin', '/opt/homebrew/bin');
|
|
824
968
|
}
|
|
825
969
|
return dirs;
|
|
@@ -845,10 +989,19 @@ const BIN_EXTS = IS_WINDOWS ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
|
845
989
|
* find it" and then opened a terminal running a bare command that could only
|
|
846
990
|
* fail with "'cursor-agent' is not recognized".
|
|
847
991
|
*
|
|
848
|
-
* Deliberately reuses
|
|
992
|
+
* Deliberately reuses getKnownBinDirs() rather than a second hardcoded list:
|
|
849
993
|
* that list already IS the curated set, and a copy of it is exactly how the
|
|
850
994
|
* launcher and the adapters drifted apart in the first place.
|
|
851
995
|
*
|
|
996
|
+
* It has to be the KNOWN dirs, not the extra ones. getExtraBinDirs() drops
|
|
997
|
+
* everything already on PATH, on the reasoning that PATH covers it — but the
|
|
998
|
+
* only way execution gets here is a `where`/`which` that ALREADY failed, and it
|
|
999
|
+
* fails wholesale on Windows (mangled OEM-codepage output on a non-English
|
|
1000
|
+
* install, a 5s timeout, a cmd.exe that isn't resolvable from an Electron
|
|
1001
|
+
* process). With the filter in place that failure erased every agent installed
|
|
1002
|
+
* to a normal location — %APPDATA%\npm and friends — and the marketplace
|
|
1003
|
+
* offered to install CodeBuddy/opencode/dsh over the copies already there.
|
|
1004
|
+
*
|
|
852
1005
|
* Runs only after a PATH lookup has already missed, so it can add resolutions
|
|
853
1006
|
* but never change one that already works.
|
|
854
1007
|
*
|
|
@@ -868,7 +1021,7 @@ function resolveBinaryInKnownDirs(names, agentType) {
|
|
|
868
1021
|
if (agentType) push(path.join(getRuntimePrefix(agentType), 'node_modules', '.bin'));
|
|
869
1022
|
push(path.join(HOME, '.openagents', 'nodejs', 'node_modules', '.bin'));
|
|
870
1023
|
try { push(path.dirname(process.execPath)); } catch {}
|
|
871
|
-
for (const d of
|
|
1024
|
+
for (const d of getKnownBinDirs()) push(d);
|
|
872
1025
|
|
|
873
1026
|
for (const dir of dirs) {
|
|
874
1027
|
for (const name of list) {
|
|
@@ -887,6 +1040,7 @@ function resolveBinaryInKnownDirs(names, agentType) {
|
|
|
887
1040
|
|
|
888
1041
|
|
|
889
1042
|
module.exports = {
|
|
1043
|
+
getKnownBinDirs,
|
|
890
1044
|
getExtraBinDirs,
|
|
891
1045
|
getEnhancedPATH,
|
|
892
1046
|
getEnhancedEnv,
|