@openagents-org/agent-launcher 0.2.143 → 0.2.146
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/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +112 -115
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +44 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +36 -7
- package/src/config.js +8 -2
- package/src/daemon.js +165 -51
- package/src/installer.js +572 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
package/src/adapters/base.js
CHANGED
|
@@ -20,9 +20,21 @@
|
|
|
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 {
|
|
24
|
+
REASON,
|
|
25
|
+
classifyJoinError,
|
|
26
|
+
classifyHeartbeatError,
|
|
27
|
+
} = require('./health-status');
|
|
23
28
|
|
|
24
29
|
const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
|
|
25
30
|
|
|
31
|
+
// Heartbeat runs every 30s. A SINGLE failure is usually a transient blip (brief
|
|
32
|
+
// network hiccup, server redeploy) that the next tick recovers from — surfacing
|
|
33
|
+
// it as a hard 'error' would make the agent flap red for no real reason. Only
|
|
34
|
+
// after this many CONSECUTIVE failures (~60s+ of real downtime) do we report
|
|
35
|
+
// heartbeat_failed up to the daemon. A success resets the streak immediately.
|
|
36
|
+
const HEARTBEAT_ERROR_THRESHOLD = 2;
|
|
37
|
+
|
|
26
38
|
class BaseAdapter {
|
|
27
39
|
/**
|
|
28
40
|
* @param {object} opts
|
|
@@ -32,7 +44,7 @@ class BaseAdapter {
|
|
|
32
44
|
* @param {string} opts.agentName
|
|
33
45
|
* @param {string} [opts.endpoint]
|
|
34
46
|
*/
|
|
35
|
-
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir }) {
|
|
47
|
+
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus }) {
|
|
36
48
|
this.workspaceId = workspaceId;
|
|
37
49
|
this.channelName = channelName;
|
|
38
50
|
this.token = token;
|
|
@@ -41,9 +53,23 @@ class BaseAdapter {
|
|
|
41
53
|
this.agentEnv = agentEnv || process.env;
|
|
42
54
|
this.agentType = agentType;
|
|
43
55
|
this.workingDir = workingDir || undefined;
|
|
56
|
+
// Optional callback the daemon supplies to surface live runtime/connectivity
|
|
57
|
+
// status (reason + redacted message) into daemon.status.json so the Agents
|
|
58
|
+
// list / TUI can show the REAL failure instead of a swallowed log line. A
|
|
59
|
+
// null reason means "healthy again" (clears any prior error).
|
|
60
|
+
this._onStatus = typeof onStatus === 'function' ? onStatus : null;
|
|
61
|
+
this._lastReportedStatusKey = null;
|
|
62
|
+
// Consecutive heartbeat failures — a transient single blip must not flip the
|
|
63
|
+
// agent to a hard error (see HEARTBEAT_ERROR_THRESHOLD).
|
|
64
|
+
this._heartbeatFailStreak = 0;
|
|
65
|
+
// Structured terminal exit reason ({ reason, message }) read by the daemon
|
|
66
|
+
// after run() returns, to distinguish a clean stop from a real failure.
|
|
67
|
+
this._exitInfo = null;
|
|
68
|
+
// Set when the user explicitly stops this adapter (vs an error/revoke), so a
|
|
69
|
+
// clean stop is never mislabeled as an error.
|
|
70
|
+
this._stopRequested = false;
|
|
44
71
|
this.client = new WorkspaceClient(this.endpoint);
|
|
45
72
|
this._lastEventId = null;
|
|
46
|
-
this._lastToolResultId = null;
|
|
47
73
|
this._running = false;
|
|
48
74
|
this._sessionId = null; // issued by server on /v1/join; used to prove liveness
|
|
49
75
|
this._processedIds = new Set();
|
|
@@ -71,13 +97,62 @@ class BaseAdapter {
|
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
// ------------------------------------------------------------------
|
|
74
|
-
//
|
|
100
|
+
// Runtime status reporting (daemon surfaces this in daemon.status.json)
|
|
75
101
|
// ------------------------------------------------------------------
|
|
76
102
|
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Surface a live status transition to the daemon. `reason` null/'' means the
|
|
105
|
+
* agent is healthy again (clears any prior error). Deduped so a repeated
|
|
106
|
+
* failure (e.g. heartbeat every 30s on a down workspace) writes the status
|
|
107
|
+
* file once, not on every tick. Never throws — status is best-effort.
|
|
108
|
+
*/
|
|
109
|
+
_reportStatus(reason, message) {
|
|
110
|
+
const key = `${reason || ''}|${message || ''}`;
|
|
111
|
+
if (key === this._lastReportedStatusKey) return;
|
|
112
|
+
this._lastReportedStatusKey = key;
|
|
113
|
+
if (!this._onStatus) return;
|
|
114
|
+
try {
|
|
115
|
+
this._onStatus({ reason: reason || null, message: message || null });
|
|
116
|
+
} catch { /* status is best-effort */ }
|
|
117
|
+
}
|
|
79
118
|
|
|
80
|
-
|
|
119
|
+
/** Record the FIRST terminal failure reason; later teardown noise can't mask it. */
|
|
120
|
+
_setExitInfo(reason, message) {
|
|
121
|
+
if (!this._exitInfo) this._exitInfo = { reason, message };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read by the daemon after run() returns. null = clean exit. */
|
|
125
|
+
getExitInfo() {
|
|
126
|
+
return this._exitInfo;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True when stop() was called explicitly (a clean user stop, not a failure). */
|
|
130
|
+
wasStopRequested() {
|
|
131
|
+
return this._stopRequested === true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Preflight gate, run by the daemon BEFORE join. Default: always runnable.
|
|
136
|
+
* Subclasses whose agent needs a resolvable CLI binary override this to return
|
|
137
|
+
* { ok:false, reason:'runtime_missing', message } so the daemon surfaces a
|
|
138
|
+
* precise reason and skips the workspace join (no pointless join loop).
|
|
139
|
+
*/
|
|
140
|
+
preflight() {
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------
|
|
145
|
+
// Lifecycle
|
|
146
|
+
// ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Announce this agent to the workspace (/v1/join). Returns true on success.
|
|
150
|
+
* On failure surfaces the REAL reason (e.g. "Workspace join failed: HTTP 401")
|
|
151
|
+
* to the daemon status instead of only logging it — non-fatal, the poll/
|
|
152
|
+
* heartbeat loops keep retrying and a later success clears it. Extracted from
|
|
153
|
+
* run() so the failure-reporting can be unit-tested without the poll loop.
|
|
154
|
+
*/
|
|
155
|
+
async _joinWorkspace() {
|
|
81
156
|
try {
|
|
82
157
|
const joinResult = await this.client.joinNetwork(this.agentName, this.token, {
|
|
83
158
|
network: this.workspaceId,
|
|
@@ -87,9 +162,21 @@ class BaseAdapter {
|
|
|
87
162
|
});
|
|
88
163
|
this._sessionId = (joinResult && joinResult.session_id) || null;
|
|
89
164
|
this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
|
|
165
|
+
this._reportStatus(null); // joined OK → clear any prior error
|
|
166
|
+
return true;
|
|
90
167
|
} catch (e) {
|
|
91
|
-
|
|
168
|
+
const { reason, message } = classifyJoinError(e);
|
|
169
|
+
this._log(`${message} (status: ${e && e.statusCode != null ? e.statusCode : 'n/a'})`);
|
|
170
|
+
this._reportStatus(reason, message);
|
|
171
|
+
return false;
|
|
92
172
|
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async run() {
|
|
176
|
+
this._running = true;
|
|
177
|
+
|
|
178
|
+
// Announce agent to workspace
|
|
179
|
+
await this._joinWorkspace();
|
|
93
180
|
|
|
94
181
|
// Sync workspace-managed skills into disabledModules
|
|
95
182
|
try {
|
|
@@ -137,6 +224,7 @@ class BaseAdapter {
|
|
|
137
224
|
}
|
|
138
225
|
|
|
139
226
|
stop() {
|
|
227
|
+
this._stopRequested = true;
|
|
140
228
|
this._running = false;
|
|
141
229
|
}
|
|
142
230
|
|
|
@@ -164,13 +252,25 @@ class BaseAdapter {
|
|
|
164
252
|
async _heartbeat() {
|
|
165
253
|
try {
|
|
166
254
|
await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
|
|
255
|
+
this._heartbeatFailStreak = 0;
|
|
256
|
+
this._reportStatus(null); // alive → clear any prior connectivity error
|
|
167
257
|
} catch (e) {
|
|
168
258
|
if (e instanceof SessionRevokedError) {
|
|
169
259
|
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
|
|
260
|
+
// Terminal (not a user stop): record so the daemon can show why it ended.
|
|
261
|
+
this._setExitInfo(REASON.SESSION_REVOKED, 'Workspace session revoked — another client joined with the same agent name');
|
|
262
|
+
this._reportStatus(REASON.SESSION_REVOKED, 'Workspace session revoked');
|
|
170
263
|
this._running = false;
|
|
171
264
|
return;
|
|
172
265
|
}
|
|
173
|
-
|
|
266
|
+
// Only surface a hard error after repeated consecutive failures, so a
|
|
267
|
+
// single transient blip (or an expected brief reconnect) isn't mislabeled.
|
|
268
|
+
this._heartbeatFailStreak++;
|
|
269
|
+
const { reason, message } = classifyHeartbeatError(e);
|
|
270
|
+
this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`);
|
|
271
|
+
if (this._heartbeatFailStreak >= HEARTBEAT_ERROR_THRESHOLD) {
|
|
272
|
+
this._reportStatus(reason, message);
|
|
273
|
+
}
|
|
174
274
|
}
|
|
175
275
|
}
|
|
176
276
|
|
|
@@ -552,31 +652,6 @@ class BaseAdapter {
|
|
|
552
652
|
idleCount++;
|
|
553
653
|
}
|
|
554
654
|
|
|
555
|
-
// Sidecar poll: A2UI tool_result events. These are the user's response
|
|
556
|
-
// to a UI spec this agent (or any agent in the network) emitted. We
|
|
557
|
-
// surface each one as a synthetic user message so the LLM sees it as
|
|
558
|
-
// the next turn and can react. Failures here don't break the main
|
|
559
|
-
// message poll.
|
|
560
|
-
try {
|
|
561
|
-
const toolResult = await this.client.pollToolResults(
|
|
562
|
-
this.workspaceId, this.token,
|
|
563
|
-
{ after: this._lastToolResultId }
|
|
564
|
-
);
|
|
565
|
-
if (toolResult.cursor) this._lastToolResultId = toolResult.cursor;
|
|
566
|
-
for (const event of toolResult.events || []) {
|
|
567
|
-
const msgId = event.id;
|
|
568
|
-
if (msgId && this._processedIds.has(msgId)) continue;
|
|
569
|
-
if (msgId) this._processedIds.add(msgId);
|
|
570
|
-
const synth = synthesizeToolResultMessage(event);
|
|
571
|
-
if (synth) await this._dispatchMessage(synth);
|
|
572
|
-
}
|
|
573
|
-
} catch (e) {
|
|
574
|
-
// Non-fatal — log once per poll if it fails
|
|
575
|
-
if (pollCount <= 3 || pollCount % 20 === 0) {
|
|
576
|
-
this._log(`tool_result poll #${pollCount} failed: ${e.message}`);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
655
|
// Adaptive polling with warm plateau:
|
|
581
656
|
// Active (messages incoming): 2s
|
|
582
657
|
// Warm (≤5 min since last msg): 5s
|
|
@@ -710,15 +785,10 @@ class BaseAdapter {
|
|
|
710
785
|
}
|
|
711
786
|
|
|
712
787
|
async sendThinking(channel, content) {
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
// payload.spec extraction, so showing the raw block here is just noise
|
|
716
|
-
// (and a duplicate). If stripping leaves the thinking message empty,
|
|
717
|
-
// skip it entirely.
|
|
718
|
-
const { cleanContent } = extractA2UISpec(content);
|
|
719
|
-
if (!cleanContent || !cleanContent.trim()) return;
|
|
788
|
+
// Skip empty thinking traces entirely.
|
|
789
|
+
if (!content || !content.trim()) return;
|
|
720
790
|
try {
|
|
721
|
-
await this.client.sendMessage(this.workspaceId, channel, this.token,
|
|
791
|
+
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
722
792
|
senderType: 'agent',
|
|
723
793
|
senderName: this.agentName,
|
|
724
794
|
messageType: 'thinking',
|
|
@@ -731,14 +801,11 @@ class BaseAdapter {
|
|
|
731
801
|
}
|
|
732
802
|
|
|
733
803
|
async sendResponse(channel, content) {
|
|
734
|
-
const { cleanContent, spec, specToolCallId } = extractA2UISpec(content);
|
|
735
804
|
try {
|
|
736
|
-
await this.client.sendMessage(this.workspaceId, channel, this.token,
|
|
805
|
+
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
737
806
|
senderType: 'agent',
|
|
738
807
|
senderName: this.agentName,
|
|
739
808
|
sessionId: this._sessionId,
|
|
740
|
-
spec,
|
|
741
|
-
specToolCallId,
|
|
742
809
|
});
|
|
743
810
|
} catch (e) {
|
|
744
811
|
if (e instanceof SessionRevokedError) {
|
|
@@ -865,74 +932,4 @@ class BaseAdapter {
|
|
|
865
932
|
}
|
|
866
933
|
}
|
|
867
934
|
|
|
868
|
-
// ------------------------------------------------------------------
|
|
869
|
-
// A2UI helpers
|
|
870
|
-
// ------------------------------------------------------------------
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Pull the first ```a2ui ... ``` fenced block out of LLM-produced content.
|
|
874
|
-
* Returns the content with the block stripped, the parsed spec, and a
|
|
875
|
-
* tool-call id derived from `spec.tool_call_id` (if present) or a new one.
|
|
876
|
-
* If no block is present or parsing fails, returns the content unchanged
|
|
877
|
-
* with null spec — the message still goes out as plain markdown.
|
|
878
|
-
*/
|
|
879
|
-
/**
|
|
880
|
-
* Convert a workspace.tool_result event into a synthetic user-message
|
|
881
|
-
* shape that the agent's _handleMessage can dispatch. The LLM sees this
|
|
882
|
-
* as the next user turn — the content is a short, machine-readable line
|
|
883
|
-
* the LLM can parse without ambiguity. The original spec it emitted is
|
|
884
|
-
* already in the LLM's conversation history; the tool_call_id lets the
|
|
885
|
-
* LLM correlate this back.
|
|
886
|
-
*/
|
|
887
|
-
function synthesizeToolResultMessage(event) {
|
|
888
|
-
if (!event || !event.payload) return null;
|
|
889
|
-
const p = event.payload;
|
|
890
|
-
const actionId = p.action_id || '';
|
|
891
|
-
const toolCallId = p.tool_call_id || '';
|
|
892
|
-
let valueStr = '';
|
|
893
|
-
if (p.value !== undefined && p.value !== null) {
|
|
894
|
-
try { valueStr = JSON.stringify(p.value); } catch (_) { valueStr = String(p.value); }
|
|
895
|
-
}
|
|
896
|
-
const lines = [
|
|
897
|
-
'[ui_action]',
|
|
898
|
-
`action=${actionId}`,
|
|
899
|
-
toolCallId ? `tool_call_id=${toolCallId}` : null,
|
|
900
|
-
valueStr ? `value=${valueStr}` : null,
|
|
901
|
-
].filter(Boolean);
|
|
902
|
-
const content = lines.join(' ');
|
|
903
|
-
const target = event.target || '';
|
|
904
|
-
return {
|
|
905
|
-
messageId: event.id || '',
|
|
906
|
-
sessionId: target.startsWith('channel/') ? target.replace('channel/', '') : target,
|
|
907
|
-
senderType: 'human',
|
|
908
|
-
senderName: 'user',
|
|
909
|
-
content,
|
|
910
|
-
mentions: [],
|
|
911
|
-
messageType: 'chat',
|
|
912
|
-
metadata: event.metadata || {},
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
function extractA2UISpec(content) {
|
|
917
|
-
if (!content || typeof content !== 'string') {
|
|
918
|
-
return { cleanContent: content, spec: null, specToolCallId: null };
|
|
919
|
-
}
|
|
920
|
-
const match = content.match(/```a2ui\s*\n([\s\S]*?)\n```/);
|
|
921
|
-
if (!match) return { cleanContent: content, spec: null, specToolCallId: null };
|
|
922
|
-
|
|
923
|
-
let spec;
|
|
924
|
-
try {
|
|
925
|
-
spec = JSON.parse(match[1]);
|
|
926
|
-
} catch (_) {
|
|
927
|
-
return { cleanContent: content, spec: null, specToolCallId: null };
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const specToolCallId = (spec && spec.tool_call_id) || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
931
|
-
if (spec && spec.tool_call_id) delete spec.tool_call_id;
|
|
932
|
-
|
|
933
|
-
const cleanContent = content.replace(match[0], '').trim();
|
|
934
|
-
return { cleanContent, spec, specToolCallId };
|
|
935
|
-
}
|
|
936
|
-
|
|
937
935
|
module.exports = BaseAdapter;
|
|
938
|
-
module.exports.extractA2UISpec = extractA2UISpec;
|
package/src/adapters/claude.js
CHANGED
|
@@ -19,7 +19,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
19
19
|
const BaseAdapter = require('./base');
|
|
20
20
|
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
21
21
|
const { buildClaudeSystemPrompt, buildClaudeSkillMd } = require('./workspace-prompt');
|
|
22
|
-
const { defaultAgentWorkdir, whichBinary,
|
|
22
|
+
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
|
|
23
23
|
|
|
24
24
|
const IS_WINDOWS = process.platform === 'win32';
|
|
25
25
|
|
|
@@ -37,6 +37,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
37
37
|
this._channelSessions = {}; // channel → Claude CLI session_id
|
|
38
38
|
this._channelProcesses = {}; // channel → child process
|
|
39
39
|
this._stoppingChannels = new Set();
|
|
40
|
+
// Channels that have already announced "Execution stopped by user." for the
|
|
41
|
+
// current stop. Two paths race to post it (the control-action handler that
|
|
42
|
+
// kills the process, and the in-flight message handler that sees
|
|
43
|
+
// pp.userStopped after exit), so this dedups to a single notice. Reset when
|
|
44
|
+
// a new message starts processing in the channel.
|
|
45
|
+
this._stopNoticeSent = new Set();
|
|
40
46
|
this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
|
|
41
47
|
this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
42
48
|
this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
|
|
@@ -84,9 +90,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
84
90
|
await this._stopProcess(proc);
|
|
85
91
|
delete this._channelProcesses[channel];
|
|
86
92
|
delete this._channelQueues[channel];
|
|
87
|
-
|
|
88
|
-
await this.sendResponse(channel, 'Execution stopped by user.');
|
|
89
|
-
} catch {}
|
|
93
|
+
await this._postStopNotice(channel);
|
|
90
94
|
} else {
|
|
91
95
|
for (const pp of Object.values(this._persistentProcs)) pp.userStopped = true;
|
|
92
96
|
await this._stopAllProcesses('Execution stopped by user.');
|
|
@@ -258,6 +262,18 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
258
262
|
);
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Post "Execution stopped by user." at most once per channel for a given
|
|
267
|
+
* stop. The control-action handler and the in-flight message handler both
|
|
268
|
+
* race to announce a stop; without this guard the user sees it twice. The
|
|
269
|
+
* guard is reset when a new message starts processing in the channel.
|
|
270
|
+
*/
|
|
271
|
+
async _postStopNotice(channel) {
|
|
272
|
+
if (!channel || this._stopNoticeSent.has(channel)) return;
|
|
273
|
+
this._stopNoticeSent.add(channel);
|
|
274
|
+
try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
|
|
275
|
+
}
|
|
276
|
+
|
|
261
277
|
async _stopAllProcesses(completionMessage = 'Execution stopped.') {
|
|
262
278
|
for (const channel of Object.keys(this._persistentProcs)) {
|
|
263
279
|
this._killPersistentProc(channel);
|
|
@@ -288,7 +304,9 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
288
304
|
for (const c of candidates) {
|
|
289
305
|
if (fs.existsSync(c)) return c;
|
|
290
306
|
}
|
|
291
|
-
|
|
307
|
+
// Fall back to the node already running this daemon (absolute, always valid).
|
|
308
|
+
// Bare 'node' can be off-PATH in a packaged daemon or CI runner.
|
|
309
|
+
return process.execPath;
|
|
292
310
|
}
|
|
293
311
|
|
|
294
312
|
/**
|
|
@@ -343,24 +361,13 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
343
361
|
const portableCandidate = path.join(portableBin, `claude${ext}`);
|
|
344
362
|
if (fs.existsSync(portableCandidate)) return portableCandidate;
|
|
345
363
|
|
|
346
|
-
// Tier 1: PATH search
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (IS_WINDOWS) {
|
|
354
|
-
const r = execSync('where claude.cmd 2>nul || where claude.exe 2>nul || where claude 2>nul', {
|
|
355
|
-
encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
|
|
356
|
-
});
|
|
357
|
-
const hit = r.split(/\r?\n/)[0].trim();
|
|
358
|
-
if (hit) return hit;
|
|
359
|
-
} else {
|
|
360
|
-
const hit = execSync('which claude', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
|
|
361
|
-
if (hit) return hit;
|
|
362
|
-
}
|
|
363
|
-
} catch {}
|
|
364
|
+
// Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
|
|
365
|
+
// output + verifies existence, so a non-ASCII/Chinese username isn't mangled
|
|
366
|
+
// into an ENOENT). Uses the ENRICHED env so a packaged daemon's minimal PATH
|
|
367
|
+
// still sees the node-version-manager / homebrew / npm-global dirs the
|
|
368
|
+
// launcher adds — that's why a bare `which claude` came up empty before.
|
|
369
|
+
const viaWhere = whereBinary('claude');
|
|
370
|
+
if (viaWhere) return viaWhere;
|
|
364
371
|
|
|
365
372
|
// Tier 2: Next to current Node.js interpreter (npm global)
|
|
366
373
|
const nodeBinDir = path.dirname(process.execPath);
|
|
@@ -558,15 +565,9 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
558
565
|
const oaPortable = path.join(home3, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${oaExt}`);
|
|
559
566
|
if (fs.existsSync(oaPortable)) oaBin = oaPortable;
|
|
560
567
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
encoding: 'utf-8', timeout: 5000,
|
|
565
|
-
}).split(/\r?\n/)[0].trim();
|
|
566
|
-
} else {
|
|
567
|
-
oaBin = execSync('which openagents', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
568
|
-
}
|
|
569
|
-
} catch {}
|
|
568
|
+
// Codepage-safe lookup so a non-ASCII/Chinese username in the path isn't
|
|
569
|
+
// mangled into an ENOENT (see whereBinary).
|
|
570
|
+
if (!oaBin) oaBin = whereBinary('openagents');
|
|
570
571
|
if (!oaBin) {
|
|
571
572
|
this._log('Could not find openagents binary — MCP tools may not be available');
|
|
572
573
|
mcpCommand = 'openagents';
|
|
@@ -937,6 +938,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
937
938
|
|
|
938
939
|
const msgChannel = msg.sessionId || this.channelName;
|
|
939
940
|
this._stoppingChannels.delete(msgChannel);
|
|
941
|
+
this._stopNoticeSent.delete(msgChannel);
|
|
940
942
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
941
943
|
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
942
944
|
|
|
@@ -1001,7 +1003,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1001
1003
|
}
|
|
1002
1004
|
if (existingPP.userStopped) {
|
|
1003
1005
|
if (!existingPP.everPostedAnything) {
|
|
1004
|
-
|
|
1006
|
+
await this._postStopNotice(msgChannel);
|
|
1005
1007
|
}
|
|
1006
1008
|
return;
|
|
1007
1009
|
}
|
|
@@ -1101,7 +1103,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1101
1103
|
this._log(`Process exited during first message (attempt ${attempt + 1}), userStopped=${pp.userStopped}`);
|
|
1102
1104
|
if (pp.userStopped) {
|
|
1103
1105
|
if (!pp.everPostedAnything) {
|
|
1104
|
-
|
|
1106
|
+
await this._postStopNotice(msgChannel);
|
|
1105
1107
|
}
|
|
1106
1108
|
break;
|
|
1107
1109
|
}
|