@openagents-org/agent-launcher 0.2.179 → 0.2.181
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/copilot-stream-parser.js +58 -8
- package/src/adapters/copilot.js +14 -3
- package/src/adapters/opencode.js +82 -20
- package/src/adapters/workspace-prompt.js +22 -2
- package/src/daemon.js +55 -0
- package/src/index.js +120 -0
- package/src/paths.js +65 -10
- package/src/workspace-client.js +38 -0
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;
|
|
@@ -168,17 +168,50 @@ function parseLine(line) {
|
|
|
168
168
|
* When real samples are available, extend THIS table only.
|
|
169
169
|
*/
|
|
170
170
|
const EVENT_KIND_BY_TYPE = {
|
|
171
|
-
//
|
|
171
|
+
// ── CAPTURED from a real authenticated run (CLI v1.0.83) ────────────────
|
|
172
|
+
// Every name in this block was read off a live JSONL stream, not inferred.
|
|
173
|
+
// The payload of each sits under `data` (see the `data` unwrap in
|
|
174
|
+
// classifyEvent) — except `result`, which is flat.
|
|
175
|
+
//
|
|
176
|
+
// The full type list observed in one turn: assistant.turn_start,
|
|
177
|
+
// assistant.reasoning_delta, assistant.reasoning, assistant.message_start,
|
|
178
|
+
// assistant.message_delta, assistant.turn_end, assistant.idle,
|
|
179
|
+
// model.call_start, model.call_finished, session.auto_mode_resolved,
|
|
180
|
+
// session.usage_checkpoint, session.tools_updated, session.skills_loaded,
|
|
181
|
+
// session.mcp_servers_loaded, session.mcp_server_status_changed,
|
|
182
|
+
// session.custom_agents_updated, user.message, result.
|
|
183
|
+
//
|
|
184
|
+
// The ones with no entry here are deliberate: they carry no content the
|
|
185
|
+
// workspace shows (lifecycle bookkeeping, or `user.message`, which is our
|
|
186
|
+
// own prompt echoed back). They degrade to `unknown`, which is logged and
|
|
187
|
+
// ignored — mapping them would put noise in the chat.
|
|
188
|
+
'assistant.message_delta': 'text_delta', // data.deltaContent — THE answer
|
|
189
|
+
'assistant.reasoning': 'reasoning', // data.content — ONE whole block
|
|
190
|
+
result: 'done', // flat: sessionId, exitCode, usage
|
|
191
|
+
// NOT mapped on purpose: `assistant.reasoning_delta`. Copilot streams at
|
|
192
|
+
// TOKEN granularity — 81 deltas for one short thought in the captured turn —
|
|
193
|
+
// and every adapter here narrates progress in whole blocks (claude sends
|
|
194
|
+
// `block.text`, codex `item.text`). Mapping the deltas posted one chat
|
|
195
|
+
// message per token: a column of "How" / "can" / "I" / "help" / "?" in the
|
|
196
|
+
// workspace. `assistant.reasoning` carries the same content as a single
|
|
197
|
+
// block afterwards, so that is what gets narrated.
|
|
198
|
+
'session.usage_checkpoint': 'usage',
|
|
199
|
+
'session.auto_mode_resolved': 'usage', // data.chosenModel under Auto
|
|
200
|
+
'model.call_finished': 'usage',
|
|
201
|
+
|
|
202
|
+
// ── INFERRED — no live sample yet ───────────────────────────────────────
|
|
203
|
+
// The captured turn was a plain greeting, so no tool/file/shell/permission
|
|
204
|
+
// event appeared in it. The names below predate that capture and are kept
|
|
205
|
+
// as a net: they cost nothing (the real names above are matched first) and
|
|
206
|
+
// an unrecognized event is logged, never rendered. Replace them the moment
|
|
207
|
+
// a tool-using turn is captured — do not add more guesses.
|
|
172
208
|
session: 'session', 'session.created': 'session', 'session.started': 'session',
|
|
173
209
|
session_started: 'session', thread: 'session', 'thread.started': 'session',
|
|
174
|
-
// streaming assistant text (qualified names only — no bare 'delta'/'token')
|
|
175
210
|
'text.delta': 'text_delta', text_delta: 'text_delta',
|
|
176
211
|
'message.delta': 'text_delta', 'assistant.delta': 'text_delta',
|
|
177
212
|
content_block_delta: 'text_delta',
|
|
178
|
-
// final assistant text (qualified/explicit names only — no bare 'message')
|
|
179
213
|
text: 'text', 'message.completed': 'text', 'assistant.message': 'text',
|
|
180
214
|
assistant: 'text', completion: 'text', agent_message: 'text',
|
|
181
|
-
// reasoning / status narration
|
|
182
215
|
reasoning: 'reasoning', thinking: 'reasoning', thought: 'reasoning',
|
|
183
216
|
status: 'reasoning', progress: 'reasoning',
|
|
184
217
|
// tool calls
|
|
@@ -250,7 +283,14 @@ function classifyEvent(obj) {
|
|
|
250
283
|
const itemKind = itemType ? EVENT_KIND_BY_TYPE[itemType.toLowerCase()] : undefined;
|
|
251
284
|
if (itemKind) kind = itemKind;
|
|
252
285
|
}
|
|
253
|
-
|
|
286
|
+
// Copilot puts every event's payload under `data`, with the envelope
|
|
287
|
+
// (type/id/timestamp/parentId) on the outside — so reading fields off the
|
|
288
|
+
// top level found nothing and the answer came back empty. `result` is the
|
|
289
|
+
// one exception: it is flat, and falls through to `obj` here.
|
|
290
|
+
const data = (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data))
|
|
291
|
+
? obj.data
|
|
292
|
+
: null;
|
|
293
|
+
const src = item || data || obj;
|
|
254
294
|
|
|
255
295
|
switch (kind) {
|
|
256
296
|
case 'session': {
|
|
@@ -261,7 +301,9 @@ function classifyEvent(obj) {
|
|
|
261
301
|
return { kind: 'session', sessionId };
|
|
262
302
|
}
|
|
263
303
|
case 'text_delta': {
|
|
264
|
-
|
|
304
|
+
// `deltaContent` is Copilot's own field name, verified on v1.0.83.
|
|
305
|
+
const text = _coerceText(src.deltaContent) ?? _coerceText(src.delta)
|
|
306
|
+
?? _coerceText(src.text) ?? _coerceText(src.content) ?? _coerceText(src);
|
|
265
307
|
return { kind: 'text_delta', text: text || '' };
|
|
266
308
|
}
|
|
267
309
|
case 'text': {
|
|
@@ -269,7 +311,8 @@ function classifyEvent(obj) {
|
|
|
269
311
|
return { kind: 'text', text: text || '' };
|
|
270
312
|
}
|
|
271
313
|
case 'reasoning': {
|
|
272
|
-
const text = _coerceText(src.
|
|
314
|
+
const text = _coerceText(src.deltaContent) ?? _coerceText(src.text)
|
|
315
|
+
?? _coerceText(src.reasoning) ?? _coerceText(src.message)
|
|
273
316
|
?? _coerceText(src.content) ?? _firstString(src, ['status', 'label', 'title']) ?? '';
|
|
274
317
|
return { kind: 'reasoning', text };
|
|
275
318
|
}
|
|
@@ -313,7 +356,14 @@ function classifyEvent(obj) {
|
|
|
313
356
|
}
|
|
314
357
|
case 'done': {
|
|
315
358
|
const status = _firstString(src, ['status', 'reason', 'result']) || 'completed';
|
|
316
|
-
|
|
359
|
+
// The `result` frame is also where the real session id finally shows up
|
|
360
|
+
// — there is no session event earlier in the stream. Carrying it here is
|
|
361
|
+
// what lets the next turn resume instead of starting over. Read from the
|
|
362
|
+
// top level too: `result` is flat, so `src` is already `obj`, but an
|
|
363
|
+
// `item`-wrapped variant would not be.
|
|
364
|
+
const sessionId = _firstString(src, ['sessionId', 'session_id'])
|
|
365
|
+
?? _firstString(obj, ['sessionId', 'session_id']);
|
|
366
|
+
return sessionId ? { kind: 'done', status, sessionId } : { kind: 'done', status };
|
|
317
367
|
}
|
|
318
368
|
case 'error': {
|
|
319
369
|
const err = (src.error && typeof src.error === 'object') ? src.error : src;
|
package/src/adapters/copilot.js
CHANGED
|
@@ -606,10 +606,14 @@ class CopilotAdapter extends BaseAdapter {
|
|
|
606
606
|
}
|
|
607
607
|
break;
|
|
608
608
|
case 'text_delta':
|
|
609
|
-
//
|
|
610
|
-
//
|
|
609
|
+
// Accumulate only — never narrate. These deltas ARE the final
|
|
610
|
+
// answer being streamed a token at a time (Copilot's
|
|
611
|
+
// `assistant.message_delta`), and the answer is posted in full
|
|
612
|
+
// when the turn ends. Sending each one as `thinking` published the
|
|
613
|
+
// reply twice: once as a column of one-word messages, then again
|
|
614
|
+
// whole.
|
|
611
615
|
if (sawToolSinceText) { finalParts.length = 0; deltaBuf = ''; sawToolSinceText = false; }
|
|
612
|
-
if (ev.text)
|
|
616
|
+
if (ev.text) deltaBuf += ev.text;
|
|
613
617
|
break;
|
|
614
618
|
case 'text':
|
|
615
619
|
// A complete message supersedes the deltas streamed for this block.
|
|
@@ -655,6 +659,13 @@ class CopilotAdapter extends BaseAdapter {
|
|
|
655
659
|
errorEvent = errorEvent || { message: ev.message, code: ev.code };
|
|
656
660
|
break;
|
|
657
661
|
case 'done':
|
|
662
|
+
// Copilot reports its session id only in the terminal `result`
|
|
663
|
+
// frame — no session event precedes it — so this is the one place
|
|
664
|
+
// a resumable id can be captured.
|
|
665
|
+
if (ev.sessionId && this._channelSessions[channel] !== ev.sessionId) {
|
|
666
|
+
this._channelSessions[channel] = ev.sessionId;
|
|
667
|
+
this._saveSessions();
|
|
668
|
+
}
|
|
658
669
|
break;
|
|
659
670
|
case 'unknown':
|
|
660
671
|
// Preserve a redacted diagnostic; never crash the task.
|
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/daemon.js
CHANGED
|
@@ -43,6 +43,7 @@ class Daemon {
|
|
|
43
43
|
this._statusInterval = null;
|
|
44
44
|
this._cmdInterval = null;
|
|
45
45
|
this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
|
|
46
|
+
this._displayNameInterval = null; // pulls workspace-side renames back
|
|
46
47
|
this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
|
|
47
48
|
this._runningCommands = new Set(); // node-command ids currently executing
|
|
48
49
|
this._runtimes = []; // detected agent runtimes for the node view
|
|
@@ -225,6 +226,50 @@ class Daemon {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Reflect workspace-side renames back onto this device.
|
|
231
|
+
*
|
|
232
|
+
* The workspace is treated as authoritative for the label, which needs no
|
|
233
|
+
* timestamps to be correct: a rename started on the device writes to the
|
|
234
|
+
* workspace FIRST and only then to local config (see
|
|
235
|
+
* AgentConnector.setAgentDisplayName), so by the time this reads, the
|
|
236
|
+
* workspace already holds whatever the device just set. Anything different
|
|
237
|
+
* from local therefore came from the workspace and should win.
|
|
238
|
+
*
|
|
239
|
+
* Never throws — a workspace that is unreachable simply leaves the labels
|
|
240
|
+
* as they are until the next pass.
|
|
241
|
+
*/
|
|
242
|
+
async _syncDisplayNames() {
|
|
243
|
+
let pairings = [];
|
|
244
|
+
try {
|
|
245
|
+
pairings = require('./node-config').listPairings();
|
|
246
|
+
} catch { return; }
|
|
247
|
+
|
|
248
|
+
for (const n of pairings) {
|
|
249
|
+
if (!n.workspace_id || !n.token) continue;
|
|
250
|
+
let members = [];
|
|
251
|
+
try {
|
|
252
|
+
members = await this._nodeClientFor(n).getAgents(n.workspace_id, n.token);
|
|
253
|
+
} catch { continue; }
|
|
254
|
+
|
|
255
|
+
const byName = new Map(members.map((m) => [m.agentName, m]));
|
|
256
|
+
for (const a of this.config.getAgents()) {
|
|
257
|
+
// Scoped to this pairing's workspace, like _buildRoster: an agent in
|
|
258
|
+
// another workspace must never be relabelled from this one.
|
|
259
|
+
if (!this._agentOnNodeWorkspace(a, n)) continue;
|
|
260
|
+
const remote = byName.get(a.name);
|
|
261
|
+
if (!remote) continue;
|
|
262
|
+
const local = a.display_name || null;
|
|
263
|
+
const wanted = remote.displayName || null;
|
|
264
|
+
if (local === wanted) continue;
|
|
265
|
+
try {
|
|
266
|
+
this.config.updateAgent(a.name, { display_name: wanted || undefined });
|
|
267
|
+
this._log(`display name for ${a.name} updated from workspace`);
|
|
268
|
+
} catch {}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
228
273
|
/**
|
|
229
274
|
* True if agent `a` is bound to the node's currently-connected workspace.
|
|
230
275
|
* A missing network never matches (guards against undefined === undefined
|
|
@@ -608,6 +653,15 @@ async _runNodeCommand(n, cmd) {
|
|
|
608
653
|
15000,
|
|
609
654
|
);
|
|
610
655
|
|
|
656
|
+
// Pull display-name renames made in the workspace back onto the device, so
|
|
657
|
+
// the two sides agree whichever one the user typed into. Separate from the
|
|
658
|
+
// 10s heartbeat on purpose: a label changes rarely and this is an extra
|
|
659
|
+
// request per paired workspace, so it runs a sixth as often.
|
|
660
|
+
this._displayNameInterval = setInterval(
|
|
661
|
+
() => this._syncDisplayNames(),
|
|
662
|
+
60000,
|
|
663
|
+
);
|
|
664
|
+
|
|
611
665
|
// Detect installed/logged-in agent runtimes for the Add-agent gallery. Runs
|
|
612
666
|
// in a child process (off the event loop), refreshed periodically.
|
|
613
667
|
this._refreshRuntimes();
|
|
@@ -658,6 +712,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
658
712
|
if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
|
|
659
713
|
if (this._credReconcileInterval) clearInterval(this._credReconcileInterval);
|
|
660
714
|
if (this._runtimesInterval) clearInterval(this._runtimesInterval);
|
|
715
|
+
if (this._displayNameInterval) clearInterval(this._displayNameInterval);
|
|
661
716
|
if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
|
|
662
717
|
if (this._probeInterval) clearInterval(this._probeInterval);
|
|
663
718
|
if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
|
package/src/index.js
CHANGED
|
@@ -84,6 +84,10 @@ class AgentConnector {
|
|
|
84
84
|
const network = networks.find((n) => n.slug === a.network || n.id === a.network);
|
|
85
85
|
return {
|
|
86
86
|
name: a.name,
|
|
87
|
+
// The label to show. `name` stays the identity — callers that key on
|
|
88
|
+
// it (sessions, working dirs, workspace membership) must keep using
|
|
89
|
+
// `name`, never this.
|
|
90
|
+
displayName: a.display_name || null,
|
|
87
91
|
type,
|
|
88
92
|
role: a.role || 'worker',
|
|
89
93
|
network: a.network || null,
|
|
@@ -100,6 +104,95 @@ class AgentConnector {
|
|
|
100
104
|
return { success: true };
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The workspace an agent is bound to, as a ready-to-use client — or null
|
|
109
|
+
* when it is local-only or its network is no longer configured.
|
|
110
|
+
*
|
|
111
|
+
* Each network carries its own endpoint (a self-hosted workspace is not the
|
|
112
|
+
* official one), so the client is built per call rather than reusing
|
|
113
|
+
* `this.workspace`, the same way removeWorkspace does.
|
|
114
|
+
*/
|
|
115
|
+
_workspaceClientFor(agentName) {
|
|
116
|
+
const agent = this.config.getAgent(agentName);
|
|
117
|
+
if (!agent || !agent.network) return null;
|
|
118
|
+
const network = this.config
|
|
119
|
+
.getNetworks()
|
|
120
|
+
.find((n) => n.slug === agent.network || n.id === agent.network);
|
|
121
|
+
if (!network || !network.id) return null;
|
|
122
|
+
const endpoint = network.endpoint || (this.workspace && this.workspace.endpoint);
|
|
123
|
+
const { WorkspaceClient } = require('./workspace-client');
|
|
124
|
+
return { client: new WorkspaceClient(endpoint), network };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Set (or clear, with an empty value) an agent's display label.
|
|
129
|
+
*
|
|
130
|
+
* The agent's `name` is its identity — it keys daemon.yaml, the working
|
|
131
|
+
* directory under ~/.openagents/agents, its sessions and its workspace
|
|
132
|
+
* membership — so renaming NEVER touches it. What changes is the label the
|
|
133
|
+
* launcher and the workspace show, which is precisely what the workspace
|
|
134
|
+
* already models as `display_name` next to `agent_name`.
|
|
135
|
+
*
|
|
136
|
+
* When the agent is in a workspace the new label is pushed there too, so the
|
|
137
|
+
* two sides agree. A workspace that rejects it (a name another member
|
|
138
|
+
* already answers to) fails the whole call rather than leaving the label
|
|
139
|
+
* changed on one side only.
|
|
140
|
+
*/
|
|
141
|
+
async setAgentDisplayName(name, displayName) {
|
|
142
|
+
const agent = this.config.getAgent(name);
|
|
143
|
+
if (!agent) throw new Error(`Agent '${name}' not found`);
|
|
144
|
+
const label = (displayName || '').trim();
|
|
145
|
+
|
|
146
|
+
const bound = this._workspaceClientFor(name);
|
|
147
|
+
if (bound) {
|
|
148
|
+
await bound.client.setMemberDisplayName(
|
|
149
|
+
bound.network.id,
|
|
150
|
+
bound.network.token || '',
|
|
151
|
+
name,
|
|
152
|
+
label,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Local write comes second: if the workspace refused the label, nothing
|
|
156
|
+
// here has changed and the two sides are still in agreement.
|
|
157
|
+
this.config.updateAgent(name, { display_name: label || undefined });
|
|
158
|
+
return { success: true, displayName: label || null };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drop an agent's member row from the workspace it joined.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately SEPARATE from removeAgent rather than a flag on it. Removing
|
|
165
|
+
* an agent is a synchronous local edit that `agn remove` and the TUI call
|
|
166
|
+
* without awaiting, inside a try/catch — making it async would turn a thrown
|
|
167
|
+
* error into an unhandled rejection those callers can no longer see. So the
|
|
168
|
+
* network half lives here, and a caller that wants both awaits this first:
|
|
169
|
+
* failing to reach the workspace then leaves the agent intact locally, which
|
|
170
|
+
* is the recoverable order.
|
|
171
|
+
*
|
|
172
|
+
* A local-only agent is a no-op success — there is nothing over there.
|
|
173
|
+
*/
|
|
174
|
+
async removeAgentFromWorkspace(name) {
|
|
175
|
+
const bound = this._workspaceClientFor(name);
|
|
176
|
+
if (!bound) return { success: true, skipped: true };
|
|
177
|
+
try {
|
|
178
|
+
await bound.client.removeMember(
|
|
179
|
+
bound.network.id,
|
|
180
|
+
bound.network.token || '',
|
|
181
|
+
name,
|
|
182
|
+
);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// 404 is the goal state, reached by someone else: the member row is
|
|
185
|
+
// already gone, or the whole workspace is. Treating it as a failure
|
|
186
|
+
// would strand the agent on this device — removal happens local-last, so
|
|
187
|
+
// an error here means it cannot be removed at all. Anything else (a
|
|
188
|
+
// network blip, an auth failure) is real and must stop the removal so
|
|
189
|
+
// the user can retry rather than lose track of a live membership.
|
|
190
|
+
if (!e || e.status !== 404) throw e;
|
|
191
|
+
return { success: true, alreadyGone: true };
|
|
192
|
+
}
|
|
193
|
+
return { success: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
103
196
|
removeAgent(name) {
|
|
104
197
|
this.config.removeAgent(name);
|
|
105
198
|
return { success: true };
|
|
@@ -193,11 +286,38 @@ class AgentConnector {
|
|
|
193
286
|
return { success: true };
|
|
194
287
|
}
|
|
195
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Local half of leaving a workspace: forget the binding, keep the agent.
|
|
291
|
+
*
|
|
292
|
+
* Synchronous, and deliberately still local-only — `agn disconnect` and the
|
|
293
|
+
* TUI call it without awaiting, inside a try/catch, so an async version
|
|
294
|
+
* would turn a throw into an unhandled rejection they can no longer see.
|
|
295
|
+
* Callers that want the membership dropped too use leaveWorkspace below.
|
|
296
|
+
*/
|
|
196
297
|
disconnectWorkspace(agentName) {
|
|
197
298
|
this.config.setAgentNetwork(agentName, null);
|
|
198
299
|
return { success: true };
|
|
199
300
|
}
|
|
200
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Leave a workspace properly: drop the member row over there, and unbind
|
|
304
|
+
* here — the agent itself stays, with its config, credentials and working
|
|
305
|
+
* directory, ready to join another workspace.
|
|
306
|
+
*
|
|
307
|
+
* Leaving only the local half is the bug this replaces. The device stopped
|
|
308
|
+
* showing the agent as connected while the workspace went on listing it as a
|
|
309
|
+
* member, and nothing in the launcher could reach that row any more. The
|
|
310
|
+
* workspace-level "remove a workspace" action learned this same lesson
|
|
311
|
+
* already (see removeWorkspace) — this is the agent-level equivalent.
|
|
312
|
+
*
|
|
313
|
+
* Workspace first, local second: a failure there leaves the agent bound and
|
|
314
|
+
* retryable rather than orphaning a membership.
|
|
315
|
+
*/
|
|
316
|
+
async leaveWorkspace(agentName) {
|
|
317
|
+
await this.removeAgentFromWorkspace(agentName);
|
|
318
|
+
return this.disconnectWorkspace(agentName);
|
|
319
|
+
}
|
|
320
|
+
|
|
201
321
|
async removeWorkspace(slug) {
|
|
202
322
|
const networks = this.config.getNetworks();
|
|
203
323
|
const network = networks.find(n => n.slug === slug || n.id === slug);
|
package/src/paths.js
CHANGED
|
@@ -371,10 +371,11 @@ function _addPackageManagerPaths(dirs) {
|
|
|
371
371
|
* reasons; anything added here is picked up on both.
|
|
372
372
|
*/
|
|
373
373
|
function _addAgentInstallerPaths(dirs) {
|
|
374
|
-
// opencode — `curl -fsSL https://opencode.ai/install | bash`
|
|
375
|
-
//
|
|
376
|
-
// "launcher can't see my opencode"
|
|
377
|
-
|
|
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'));
|
|
378
379
|
|
|
379
380
|
// kimi — the npm package (@moonshot-ai/kimi-code) declares a real `kimi` bin,
|
|
380
381
|
// but its postinstall ALSO drops a native build in ~/.kimi-code/bin and puts
|
|
@@ -405,6 +406,32 @@ function _addAgentInstallerPaths(dirs) {
|
|
|
405
406
|
// _addWindowsPaths; this is the half nothing covered.
|
|
406
407
|
_push(dirs, path.join(HOME, '.hermes', 'bin'));
|
|
407
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
|
+
|
|
408
435
|
// pipx — `pipx install X` puts the executable in <PIPX_HOME>/venvs/X/bin and
|
|
409
436
|
// only SYMLINKS it into PIPX_BIN_DIR. When that link step is skipped, or its
|
|
410
437
|
// dir is one a GUI launch can't see, the venv copy is the only one there is.
|
|
@@ -438,6 +465,26 @@ function _addAgentInstallerPaths(dirs) {
|
|
|
438
465
|
}
|
|
439
466
|
}
|
|
440
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')];
|
|
483
|
+
}
|
|
484
|
+
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
485
|
+
return [path.join(dataHome, 'uv', 'tools')];
|
|
486
|
+
}
|
|
487
|
+
|
|
441
488
|
/**
|
|
442
489
|
* Roots pipx may keep its venvs under. PIPX_HOME wins when exported; otherwise
|
|
443
490
|
* pipx has moved its default once (~/.local/pipx → the platform user-data dir),
|
|
@@ -682,6 +729,17 @@ function _addUnixPaths(dirs) {
|
|
|
682
729
|
_push(dirs, '/usr/local/bin');
|
|
683
730
|
_push(dirs, '/usr/bin');
|
|
684
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
|
+
|
|
685
743
|
// npm agents install to isolated prefixes: ~/.openagents/runtimes/<type>/
|
|
686
744
|
|
|
687
745
|
// nvm
|
|
@@ -900,15 +958,12 @@ function uvToolBinDirs(pkg) {
|
|
|
900
958
|
if (process.env.XDG_BIN_HOME) dirs.push(process.env.XDG_BIN_HOME);
|
|
901
959
|
if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, '..', 'bin'));
|
|
902
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
|
+
}
|
|
903
964
|
if (IS_WINDOWS) {
|
|
904
|
-
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
905
|
-
const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
|
|
906
|
-
dirs.push(path.join(uvTools, pkg, 'Scripts'));
|
|
907
965
|
dirs.push(path.join(home, 'bin'));
|
|
908
966
|
} else {
|
|
909
|
-
const uvTools = process.env.UV_TOOL_DIR
|
|
910
|
-
|| path.join(home, '.local', 'share', 'uv', 'tools');
|
|
911
|
-
dirs.push(path.join(uvTools, pkg, 'bin'));
|
|
912
967
|
dirs.push(path.join(home, 'bin'), '/usr/local/bin', '/opt/homebrew/bin');
|
|
913
968
|
}
|
|
914
969
|
return dirs;
|
package/src/workspace-client.js
CHANGED
|
@@ -84,6 +84,40 @@ class WorkspaceClient {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Remove one agent from a workspace via
|
|
89
|
+
* DELETE /v1/workspaces/{workspaceId}/members/{agentName}.
|
|
90
|
+
*
|
|
91
|
+
* Unlike deleteWorkspace above this is NOT best-effort: the caller offered
|
|
92
|
+
* the user a choice ("also remove it from the workspace"), so a failure has
|
|
93
|
+
* to be reported rather than swallowed — silently leaving the member behind
|
|
94
|
+
* is the exact problem this exists to fix.
|
|
95
|
+
*/
|
|
96
|
+
async removeMember(workspaceId, token, agentName) {
|
|
97
|
+
await this._delete(
|
|
98
|
+
`/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}`,
|
|
99
|
+
this._wsHeaders(token),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Rename one agent's DISPLAY name in a workspace, via
|
|
105
|
+
* PATCH /v1/workspaces/{workspaceId}/members/{agentName}.
|
|
106
|
+
*
|
|
107
|
+
* The agent's identity (`agent_name`) is untouched — this is the label the
|
|
108
|
+
* workspace shows and accepts as an @-mention alias, which is why the server
|
|
109
|
+
* rejects one that clashes with another member. An empty string clears the
|
|
110
|
+
* label there, falling back to `agent_name`, which is exactly what clearing
|
|
111
|
+
* the display name on this side means too.
|
|
112
|
+
*/
|
|
113
|
+
async setMemberDisplayName(workspaceId, token, agentName, displayName) {
|
|
114
|
+
return this._patch(
|
|
115
|
+
`/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}`,
|
|
116
|
+
{ display_name: displayName || '' },
|
|
117
|
+
this._wsHeaders(token),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
/**
|
|
88
122
|
* Join a workspace via POST /v1/join.
|
|
89
123
|
*/
|
|
@@ -514,6 +548,10 @@ class WorkspaceClient {
|
|
|
514
548
|
const agents = (result && result.agents) || [];
|
|
515
549
|
return agents.map((a) => ({
|
|
516
550
|
agentName: (a.address || '').replace('openagents:', ''),
|
|
551
|
+
// The workspace's label for this agent, or null when it has none and
|
|
552
|
+
// falls back to agentName. Carried so a rename made in the workspace can
|
|
553
|
+
// be reflected back on the device.
|
|
554
|
+
displayName: a.display_name || null,
|
|
517
555
|
role: a.role || 'member',
|
|
518
556
|
status: a.status || 'offline',
|
|
519
557
|
enabledSkills: a.enabled_skills || null,
|