@openagents-org/agent-launcher 0.2.151 → 0.2.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/claude.js +358 -180
- package/src/adapters/codex.js +58 -4
- package/src/adapters/cursor.js +21 -3
- package/src/adapters/decision-log.js +181 -0
- package/src/adapters/openclaw.js +39 -2
- package/src/adapters/opencode.js +58 -10
- package/src/adapters/workspace-prompt.js +255 -29
- package/src/installer.js +59 -2
- package/src/mcp-server.js +112 -0
- package/src/paths.js +57 -0
- package/src/workspace-client.js +59 -14
package/src/adapters/claude.js
CHANGED
|
@@ -18,12 +18,67 @@ const { execSync, spawn } = require('child_process');
|
|
|
18
18
|
|
|
19
19
|
const BaseAdapter = require('./base');
|
|
20
20
|
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
21
|
-
const { buildClaudeSystemPrompt, buildClaudeSkillMd } = require('./workspace-prompt');
|
|
21
|
+
const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt');
|
|
22
|
+
const { decisionLogTitle, decisionFingerprint, pickDecisionEntry, sampleRecap } = require('./decision-log');
|
|
22
23
|
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
|
|
23
24
|
|
|
24
25
|
const IS_WINDOWS = process.platform === 'win32';
|
|
25
26
|
|
|
26
27
|
class ClaudeAdapter extends BaseAdapter {
|
|
28
|
+
/**
|
|
29
|
+
* The env the spawned `claude` CLI runs with. Pure — takes the agent's env
|
|
30
|
+
* and returns a new object; nothing here reads `this`.
|
|
31
|
+
*
|
|
32
|
+
* Two jobs:
|
|
33
|
+
*
|
|
34
|
+
* 1. Strip the CLAUDE_* / AI_AGENT variables that make the child think it is
|
|
35
|
+
* running under an SDK harness (it then takes the org-scoped auth path and
|
|
36
|
+
* gets a 403). Cloud-provider and model config is kept, and so is the
|
|
37
|
+
* subscription token from `claude setup-token` — that one IS the
|
|
38
|
+
* credential, and sweeping it up as a stray CLAUDE_* left anyone who
|
|
39
|
+
* pasted one authenticating as nobody, silently.
|
|
40
|
+
*
|
|
41
|
+
* 2. Mirror the API key into ANTHROPIC_AUTH_TOKEN for third-party relays. The
|
|
42
|
+
* CLI sends ANTHROPIC_API_KEY as `x-api-key`, which most Anthropic-
|
|
43
|
+
* compatible relays ignore — they only honor `Authorization: Bearer`, and
|
|
44
|
+
* reject everything else as 401 "invalid token / 无效的令牌". The auth token
|
|
45
|
+
* is sent as Bearer and outranks the key in Claude Code's precedence. The
|
|
46
|
+
* launcher normally sets this when saving env; this is the runtime backstop
|
|
47
|
+
* for envs saved by an older launcher or coming from anywhere else. Only
|
|
48
|
+
* for a NON-official base — api.anthropic.com wants x-api-key.
|
|
49
|
+
*
|
|
50
|
+
* @param {Record<string, string>} baseEnv
|
|
51
|
+
* @returns {Record<string, string>}
|
|
52
|
+
*/
|
|
53
|
+
static _buildChildEnv(baseEnv) {
|
|
54
|
+
const KEEP = new Set([
|
|
55
|
+
'CLAUDE_CODE_USE_VERTEX',
|
|
56
|
+
'CLAUDE_CODE_USE_BEDROCK',
|
|
57
|
+
'CLAUDE_MODEL',
|
|
58
|
+
'CLAUDE_API_KEY',
|
|
59
|
+
'CLAUDE_CODE_MAX_TURNS',
|
|
60
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
61
|
+
]);
|
|
62
|
+
const env = { ...(baseEnv || {}) };
|
|
63
|
+
for (const k of Object.keys(env)) {
|
|
64
|
+
if ((k.startsWith('CLAUDE_') && !KEEP.has(k)) || k === 'CLAUDECODE' || k === 'AI_AGENT') {
|
|
65
|
+
delete env[k];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const base = (env.ANTHROPIC_BASE_URL || '').trim();
|
|
70
|
+
const key = (env.ANTHROPIC_API_KEY || '').trim();
|
|
71
|
+
if (key && base && !(env.ANTHROPIC_AUTH_TOKEN || '').trim()) {
|
|
72
|
+
let official = false;
|
|
73
|
+
try {
|
|
74
|
+
const host = new URL(base).hostname.toLowerCase();
|
|
75
|
+
official = host === 'anthropic.com' || host.endsWith('.anthropic.com');
|
|
76
|
+
} catch { official = false; }
|
|
77
|
+
if (!official) env.ANTHROPIC_AUTH_TOKEN = key;
|
|
78
|
+
}
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
|
|
27
82
|
/**
|
|
28
83
|
* @param {object} opts - BaseAdapter opts plus:
|
|
29
84
|
* @param {Set} [opts.disabledModules]
|
|
@@ -44,6 +99,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
44
99
|
// a new message starts processing in the channel.
|
|
45
100
|
this._stopNoticeSent = new Set();
|
|
46
101
|
this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
|
|
102
|
+
this._decisionEntryIds = {}; // channel → knowledge entry id of its decision log
|
|
103
|
+
// Decision-log reads happen on EVERY message, so they get a hard short
|
|
104
|
+
// deadline instead of the client's default 15s — a workspace hiccup must
|
|
105
|
+
// cost the turn a couple of seconds, not half a minute.
|
|
106
|
+
this._DECISION_FETCH_TIMEOUT_MS = 2000;
|
|
107
|
+
this._warnedKnowledgeDisabled = false;
|
|
47
108
|
this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
48
109
|
this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
|
|
49
110
|
this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
|
|
@@ -225,43 +286,114 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
225
286
|
}
|
|
226
287
|
|
|
227
288
|
/**
|
|
228
|
-
* Build a short transcript of the channel's
|
|
229
|
-
*
|
|
230
|
-
*
|
|
289
|
+
* Build a short transcript of the channel's chat, used to re-seed context
|
|
290
|
+
* when --resume fails and we have to start a fresh Claude Code session.
|
|
291
|
+
* Samples both ends of the channel — its opening messages (where the
|
|
292
|
+
* original requirement usually lives) fetched ascending, and the most
|
|
293
|
+
* recent ones fetched descending — so a long conversation's early context
|
|
294
|
+
* is not lost to pure recency. Returns null when there's nothing useful.
|
|
231
295
|
*
|
|
232
|
-
* Excludes the user's current message (the
|
|
233
|
-
*
|
|
234
|
-
* noise and inflate the prompt without adding signal.
|
|
296
|
+
* Excludes the user's current message (the caller appends it normally)
|
|
297
|
+
* and any status/thinking events, which are mostly tool-call noise.
|
|
235
298
|
*/
|
|
236
299
|
async _buildChannelRecap(channelName, currentMessage) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (mt === 'status' || mt === 'thinking' || mt === 'loading') continue;
|
|
246
|
-
const text = (m.content || '').trim();
|
|
247
|
-
if (!text) continue;
|
|
248
|
-
if (text === currentMessage) continue;
|
|
249
|
-
const who = m.senderType === 'human'
|
|
250
|
-
? (m.senderName || 'user')
|
|
251
|
-
: (m.senderName || 'agent');
|
|
252
|
-
const truncated = text.length > 2000 ? text.slice(0, 2000) + '…' : text;
|
|
253
|
-
lines.push(`[${who}] ${truncated}`);
|
|
254
|
-
}
|
|
300
|
+
// The head window is raw events — status/thinking noise is filtered out
|
|
301
|
+
// AFTER the fetch, and an opening turn is often mostly noise, so fetch
|
|
302
|
+
// well more than the 5 chat messages the sampler will keep.
|
|
303
|
+
const [headMsgs, tailMsgs] = await Promise.all([
|
|
304
|
+
this.client.getRecentMessages(this.workspaceId, channelName, this.token, 30, { sort: 'asc' }),
|
|
305
|
+
this.client.getRecentMessages(this.workspaceId, channelName, this.token, 60),
|
|
306
|
+
]);
|
|
307
|
+
const lines = sampleRecap(headMsgs, tailMsgs, currentMessage);
|
|
255
308
|
if (lines.length === 0) return null;
|
|
256
309
|
|
|
257
|
-
const tail = lines.slice(-20).join('\n');
|
|
258
310
|
return (
|
|
259
311
|
'You previously worked in this channel but your prior session is no ' +
|
|
260
|
-
'longer available, so here is the
|
|
261
|
-
|
|
312
|
+
'longer available, so here is a recap of the conversation (its opening ' +
|
|
313
|
+
'messages and the most recent ones):\n\n' +
|
|
314
|
+
lines.join('\n')
|
|
262
315
|
);
|
|
263
316
|
}
|
|
264
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Read the channel's decision log (a knowledge entry) with a short
|
|
320
|
+
* per-request deadline (worst case one list + one get, ~2x that budget).
|
|
321
|
+
* Returns { available, state, entryId, content, error }:
|
|
322
|
+
* - available=false → knowledge module disabled, pinning inactive
|
|
323
|
+
* - state 'found' → the entry exists (entryId set; content null when a
|
|
324
|
+
* transient error hid it this turn)
|
|
325
|
+
* - state 'absent' → a successful listing confirmed no entry exists
|
|
326
|
+
* - state 'unknown' → fetch failed before existence could be confirmed;
|
|
327
|
+
* the prompt must NOT claim the log is missing, or a
|
|
328
|
+
* recovered network mid-turn produces a duplicate
|
|
329
|
+
* - error=true → transient failure; callers must NOT treat this as
|
|
330
|
+
* "log changed/empty"
|
|
331
|
+
* The matched entry id is cached per channel so the steady state is a
|
|
332
|
+
* single GET; a 404 or a soft-deleted entry (the backend keeps deleted
|
|
333
|
+
* rows readable by id with status "deleted") invalidates the cache and
|
|
334
|
+
* falls back to list+match, which only sees active entries.
|
|
335
|
+
*/
|
|
336
|
+
async _fetchDecisionLog(channel) {
|
|
337
|
+
if (this.disabledModules.has('knowledge')) {
|
|
338
|
+
if (!this._warnedKnowledgeDisabled) {
|
|
339
|
+
this._warnedKnowledgeDisabled = true;
|
|
340
|
+
this._log(
|
|
341
|
+
'Knowledge module is disabled — decision-log pinning is INACTIVE. ' +
|
|
342
|
+
'Confirmed decisions will not survive context compaction or session resets.'
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return { available: false, state: 'unknown', entryId: null, content: null, error: false };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const timeout = this._DECISION_FETCH_TIMEOUT_MS;
|
|
349
|
+
const cachedId = this._decisionEntryIds[channel];
|
|
350
|
+
if (cachedId) {
|
|
351
|
+
try {
|
|
352
|
+
const entry = await this.client.getKnowledge(this.workspaceId, this.token, cachedId, { timeout });
|
|
353
|
+
if (entry && entry.status && entry.status !== 'active') {
|
|
354
|
+
// Soft-deleted: still served by id, but dead for updates.
|
|
355
|
+
delete this._decisionEntryIds[channel];
|
|
356
|
+
// Fall through to list+match below.
|
|
357
|
+
} else {
|
|
358
|
+
return { available: true, state: 'found', entryId: cachedId, content: (entry && entry.content) || '', error: false };
|
|
359
|
+
}
|
|
360
|
+
} catch (e) {
|
|
361
|
+
if (/not found|HTTP 404/i.test(e.message || '')) {
|
|
362
|
+
delete this._decisionEntryIds[channel];
|
|
363
|
+
// Entry is gone — fall through to list+match below.
|
|
364
|
+
} else {
|
|
365
|
+
this._log(`Decision log read failed (${e.message}) — reusing last known state`);
|
|
366
|
+
return { available: true, state: 'found', entryId: cachedId, content: null, error: true };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const data = await this.client.listKnowledge(this.workspaceId, this.token, { limit: 500, timeout });
|
|
373
|
+
const entries = (data && data.entries) || [];
|
|
374
|
+
const { entry, duplicates } = pickDecisionEntry(entries, channel);
|
|
375
|
+
if (duplicates > 0) {
|
|
376
|
+
this._log(
|
|
377
|
+
`Decision log WARNING for ${channel} — ${duplicates + 1} knowledge entries share the title ` +
|
|
378
|
+
`"${decisionLogTitle(channel)}"; using the earliest. The duplicates should be merged manually.`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (!entry) return { available: true, state: 'absent', entryId: null, content: null, error: false };
|
|
382
|
+
this._decisionEntryIds[channel] = entry.id;
|
|
383
|
+
const full = await this.client.getKnowledge(this.workspaceId, this.token, entry.id, { timeout });
|
|
384
|
+
if (full && full.status && full.status !== 'active') {
|
|
385
|
+
// Deleted between the listing and the read.
|
|
386
|
+
delete this._decisionEntryIds[channel];
|
|
387
|
+
return { available: true, state: 'absent', entryId: null, content: null, error: false };
|
|
388
|
+
}
|
|
389
|
+
return { available: true, state: 'found', entryId: entry.id, content: (full && full.content) || '', error: false };
|
|
390
|
+
} catch (e) {
|
|
391
|
+
this._log(`Decision log fetch failed (${e.message}) — reusing last known state`);
|
|
392
|
+
const knownId = this._decisionEntryIds[channel] || null;
|
|
393
|
+
return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, content: null, error: true };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
265
397
|
/**
|
|
266
398
|
* Post "Execution stopped by user." at most once per channel for a given
|
|
267
399
|
* stop. The control-action handler and the in-flight message handler both
|
|
@@ -398,7 +530,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
398
530
|
return null;
|
|
399
531
|
}
|
|
400
532
|
|
|
401
|
-
_buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false } = {}) {
|
|
533
|
+
_buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false, decisionLog = null } = {}) {
|
|
402
534
|
const claudeBin = this._findClaudeBinary();
|
|
403
535
|
if (!claudeBin) {
|
|
404
536
|
throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
|
|
@@ -414,12 +546,20 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
414
546
|
mode: this._mode,
|
|
415
547
|
browserEnabled,
|
|
416
548
|
toolMode: this.toolMode,
|
|
549
|
+
decisionLog,
|
|
417
550
|
});
|
|
418
551
|
|
|
419
552
|
const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
420
553
|
|
|
421
554
|
cmd.push('--append-system-prompt', systemPrompt);
|
|
422
|
-
|
|
555
|
+
const disallowedTools = ['AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup'];
|
|
556
|
+
if (browserEnabled) {
|
|
557
|
+
// Hard-ban the native WebFetch: it can't render JS and bypasses the
|
|
558
|
+
// workspace fetch chain. Prompt-level bans proved insufficient.
|
|
559
|
+
// WebSearch stays allowed (pure search, no page fetching).
|
|
560
|
+
disallowedTools.push('WebFetch');
|
|
561
|
+
}
|
|
562
|
+
cmd.push('--disallowedTools', ...disallowedTools);
|
|
423
563
|
|
|
424
564
|
// Resume existing conversation (skipped on retry after stale session)
|
|
425
565
|
const sessionId = this._channelSessions[channelName];
|
|
@@ -454,7 +594,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
454
594
|
const workDir = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
455
595
|
const skillDir = path.join(workDir, '.claude', 'skills');
|
|
456
596
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
457
|
-
|
|
597
|
+
// Per-agent filename: agents sharing a working directory used to clobber
|
|
598
|
+
// one shared openagents-workspace.md, so uploads carried the identity of
|
|
599
|
+
// whichever agent wrote the file last. Drop that legacy file so a stale
|
|
600
|
+
// copy (with another agent's embedded identity) can never be read again.
|
|
601
|
+
const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`);
|
|
602
|
+
try { fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md')); } catch {}
|
|
458
603
|
|
|
459
604
|
const skillContent = buildClaudeSkillMd({
|
|
460
605
|
endpoint: this.endpoint,
|
|
@@ -488,8 +633,13 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
488
633
|
mcpTools.push(`${pfx}workspace_list_files`, `${pfx}workspace_read_file`);
|
|
489
634
|
mcpWriteTools.push(`${pfx}workspace_write_file`, `${pfx}workspace_delete_file`);
|
|
490
635
|
}
|
|
636
|
+
if (!this.disabledModules.has('search')) {
|
|
637
|
+
mcpTools.push(`${pfx}workspace_image_search`);
|
|
638
|
+
mcpWriteTools.push(`${pfx}workspace_image_save`);
|
|
639
|
+
}
|
|
491
640
|
if (!this.disabledModules.has('browser')) {
|
|
492
641
|
mcpTools.push(
|
|
642
|
+
`${pfx}workspace_fetch_url`,
|
|
493
643
|
`${pfx}workspace_browser_list_tabs`,
|
|
494
644
|
`${pfx}workspace_browser_snapshot`,
|
|
495
645
|
`${pfx}workspace_browser_screenshot`
|
|
@@ -593,14 +743,28 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
593
743
|
|
|
594
744
|
/**
|
|
595
745
|
* Kill a persistent process for a channel and clean up its idle timer.
|
|
746
|
+
* Returns a promise that settles once the process has actually exited —
|
|
747
|
+
* callers that spawn a REPLACEMENT process for the same channel must await
|
|
748
|
+
* it, or the old process's exit handler races the new registration (and
|
|
749
|
+
* two live processes could resume the same CLI session concurrently).
|
|
596
750
|
*/
|
|
597
751
|
_killPersistentProc(channel) {
|
|
598
752
|
const pp = this._persistentProcs[channel];
|
|
599
|
-
if (!pp) return;
|
|
753
|
+
if (!pp) return Promise.resolve();
|
|
600
754
|
if (pp.idleTimer) clearTimeout(pp.idleTimer);
|
|
601
755
|
this._stopWatchdog(pp);
|
|
602
|
-
this._stopProcess(pp.proc).catch(() => {});
|
|
603
756
|
delete this._persistentProcs[channel];
|
|
757
|
+
return this._stopProcess(pp.proc).catch(() => {});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Drop a process's channel registrations, but only if they still point at
|
|
762
|
+
* THIS process. A killed predecessor's late exit event must never unhook
|
|
763
|
+
* the replacement that was registered after it.
|
|
764
|
+
*/
|
|
765
|
+
_unregisterProc(channel, pp, proc) {
|
|
766
|
+
if (this._persistentProcs[channel] === pp) delete this._persistentProcs[channel];
|
|
767
|
+
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
604
768
|
}
|
|
605
769
|
|
|
606
770
|
/**
|
|
@@ -839,8 +1003,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
839
1003
|
pp.messageResolve({ exited: true, code });
|
|
840
1004
|
pp.messageResolve = null;
|
|
841
1005
|
}
|
|
842
|
-
|
|
843
|
-
delete this._channelProcesses[channel];
|
|
1006
|
+
this._unregisterProc(channel, pp, proc);
|
|
844
1007
|
});
|
|
845
1008
|
|
|
846
1009
|
proc.on('error', (err) => {
|
|
@@ -851,7 +1014,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
851
1014
|
pp.messageResolve({ exited: true, error: err });
|
|
852
1015
|
pp.messageResolve = null;
|
|
853
1016
|
}
|
|
854
|
-
|
|
1017
|
+
this._unregisterProc(channel, pp, proc);
|
|
855
1018
|
});
|
|
856
1019
|
|
|
857
1020
|
this._persistentProcs[channel] = pp;
|
|
@@ -913,6 +1076,92 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
913
1076
|
return `Claude error: ${msg}`;
|
|
914
1077
|
}
|
|
915
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Compose the final user-facing response for a finished turn, appending
|
|
1081
|
+
* the newest plan file in plan mode. Shared by the persistent fast-path
|
|
1082
|
+
* and the fresh-spawn path so their behavior can never drift.
|
|
1083
|
+
*/
|
|
1084
|
+
_composeFinalResponse(pp) {
|
|
1085
|
+
if (this._mode === 'plan') {
|
|
1086
|
+
try {
|
|
1087
|
+
const planDir = path.join(this.workingDir || defaultAgentWorkdir(this.agentName), '.claude', 'plans');
|
|
1088
|
+
if (fs.existsSync(planDir)) {
|
|
1089
|
+
const planFiles = fs.readdirSync(planDir)
|
|
1090
|
+
.filter((f) => f.endsWith('.md'))
|
|
1091
|
+
.map((f) => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
|
|
1092
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
1093
|
+
if (planFiles.length > 0) {
|
|
1094
|
+
const planContent = fs.readFileSync(path.join(planDir, planFiles[0].name), 'utf-8').trim();
|
|
1095
|
+
if (planContent) pp.lastResponseText.push('\n\n---\n\n**Plan:**\n\n' + planContent);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
} catch {}
|
|
1099
|
+
}
|
|
1100
|
+
return pp.lastResponseText.join('\n').trim();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** True when the CLI reported the resumed conversation no longer fits. */
|
|
1104
|
+
_isPromptTooLong(pp, finalResponse) {
|
|
1105
|
+
return /prompt is too long/i.test(finalResponse || '') ||
|
|
1106
|
+
/prompt is too long/i.test(pp.lastErrorText || '');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Reset a channel's CLI session after its context outgrew the model
|
|
1111
|
+
* window: clear the stored session id, kill the persistent process, and
|
|
1112
|
+
* tell the user. This used to happen silently, which made the follow-up
|
|
1113
|
+
* turn's partial memory look like the agent spontaneously forgot things.
|
|
1114
|
+
*/
|
|
1115
|
+
async _resetSessionForPromptTooLong(msgChannel) {
|
|
1116
|
+
this._log('Prompt too long, clearing session and retrying');
|
|
1117
|
+
delete this._channelSessions[msgChannel];
|
|
1118
|
+
this._saveSessions();
|
|
1119
|
+
await this._killPersistentProc(msgChannel);
|
|
1120
|
+
try {
|
|
1121
|
+
await this.sendStatus(
|
|
1122
|
+
msgChannel,
|
|
1123
|
+
'Claude context reached its limit. Starting a fresh session from the channel recap and pinned decisions.'
|
|
1124
|
+
);
|
|
1125
|
+
} catch {}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Post the outcome of a finished turn: the final response, or the captured
|
|
1130
|
+
* error, or — when the CLI ended with neither — a session-reset notice so
|
|
1131
|
+
* the UI never hangs on "thinking…".
|
|
1132
|
+
*/
|
|
1133
|
+
async _postTurnOutcome(pp, msgChannel, finalResponse) {
|
|
1134
|
+
if (finalResponse) {
|
|
1135
|
+
try { await this.sendResponse(msgChannel, finalResponse); } catch {}
|
|
1136
|
+
} else if (pp.lastErrorText) {
|
|
1137
|
+
try { await this.sendError(msgChannel, this._formatClaudeError(pp.lastErrorText)); } catch {}
|
|
1138
|
+
} else if (!pp.everPostedAnything) {
|
|
1139
|
+
if (this._channelSessions[msgChannel]) {
|
|
1140
|
+
delete this._channelSessions[msgChannel];
|
|
1141
|
+
try { this._saveSessions(); } catch {}
|
|
1142
|
+
this._log(`Empty-error result — cleared session for ${msgChannel}`);
|
|
1143
|
+
}
|
|
1144
|
+
try { await this.sendError(msgChannel, 'The agent hit an error and could not respond. The session was reset — please send the message again.'); } catch {}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Queue a reminder about unfinished todos (skipped when this turn IS one). */
|
|
1149
|
+
async _queueTodoNudge(msgChannel, msg) {
|
|
1150
|
+
if (msg._todoNudge) return;
|
|
1151
|
+
try {
|
|
1152
|
+
const remaining = await this.getRemainingTodos(msgChannel);
|
|
1153
|
+
if (remaining.length > 0) {
|
|
1154
|
+
const items = remaining.map((t) => `- ${t.content}`).join('\n');
|
|
1155
|
+
const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
|
|
1156
|
+
if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
|
|
1157
|
+
this._channelQueues[msgChannel].push({
|
|
1158
|
+
content: nudge, senderType: 'system', senderName: 'system:todos',
|
|
1159
|
+
sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
} catch {}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
916
1165
|
async _handleMessage(msg) {
|
|
917
1166
|
let content = (msg.content || '').trim();
|
|
918
1167
|
const attachments = msg.attachments || [];
|
|
@@ -956,102 +1205,72 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
956
1205
|
|
|
957
1206
|
await this.sendStatus(msgChannel, 'thinking...');
|
|
958
1207
|
|
|
1208
|
+
// Read the channel's decision log up front — the fast-path staleness
|
|
1209
|
+
// check and any (re)spawn below both need it.
|
|
1210
|
+
const decisions = await this._fetchDecisionLog(msgChannel);
|
|
1211
|
+
// null fingerprint = fetch failed, current state unknown; never treat
|
|
1212
|
+
// that as a change (it would churn respawns on every workspace hiccup).
|
|
1213
|
+
// The fingerprint covers entry id AND content — both are pinned into the
|
|
1214
|
+
// prompt, and a recreated entry can change id without changing content.
|
|
1215
|
+
const decisionHash = decisions.error ? null : decisionFingerprint(decisions.entryId, decisions.content);
|
|
1216
|
+
const decisionLogOpt = decisions.available
|
|
1217
|
+
? { enabled: true, state: decisions.state, entryId: decisions.entryId, content: decisions.error ? '' : (decisions.content || '') }
|
|
1218
|
+
: null;
|
|
1219
|
+
|
|
959
1220
|
// ── Persistent process fast-path ──
|
|
960
1221
|
// If we have a living persistent process for this channel, send via stdin
|
|
961
1222
|
// instead of spawning a new CLI (saves ~2s startup time).
|
|
962
1223
|
const existingPP = this._persistentProcs[msgChannel];
|
|
963
1224
|
if (existingPP && existingPP.alive) {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
try { await this.sendError(msgChannel, 'The agent hit an error and could not respond. The session was reset — please send the message again.'); } catch {}
|
|
983
|
-
}
|
|
1225
|
+
// Everything below was baked into the process at spawn time — the
|
|
1226
|
+
// system prompt AND the CLI permission flags (plan mode is read-only,
|
|
1227
|
+
// execute skips permissions) — so a process from the other mode can
|
|
1228
|
+
// never be reused. This check is deliberately independent of the
|
|
1229
|
+
// decision fingerprint: a failed decision fetch must not keep a
|
|
1230
|
+
// read-only plan process serving execute requests.
|
|
1231
|
+
const modeStale = existingPP.spawnMode !== this._mode;
|
|
1232
|
+
const decisionsStale = decisionHash !== null && existingPP.decisionHash !== decisionHash;
|
|
1233
|
+
if (modeStale || decisionsStale) {
|
|
1234
|
+
// Kill it and fall through to a fresh spawn: --resume keeps the
|
|
1235
|
+
// conversation (it lives in the CLI transcript), while the new spawn
|
|
1236
|
+
// carries the current mode and re-pins the current decisions.
|
|
1237
|
+
this._log(modeStale
|
|
1238
|
+
? `Mode changed to ${this._mode} for ${msgChannel} — respawning with resume`
|
|
1239
|
+
: `Decision log changed for ${msgChannel} — respawning with resume to re-pin decisions`);
|
|
1240
|
+
await this._killPersistentProc(msgChannel);
|
|
1241
|
+
} else {
|
|
1242
|
+
this._log(`Reusing persistent process for ${msgChannel}`);
|
|
984
1243
|
this._resetIdleTimer(msgChannel);
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1244
|
+
existingPP.msgChannel = msgChannel;
|
|
1245
|
+
const result = await this._sendToPersistentProc(existingPP, content);
|
|
1246
|
+
if (result.resultEvent) {
|
|
1247
|
+
const finalResponse = this._composeFinalResponse(existingPP);
|
|
1248
|
+
if (this._isPromptTooLong(existingPP, finalResponse) && this._channelSessions[msgChannel]) {
|
|
1249
|
+
// Fall through to the fresh-spawn path: the session id is gone,
|
|
1250
|
+
// so it rebuilds context from the channel recap.
|
|
1251
|
+
await this._resetSessionForPromptTooLong(msgChannel);
|
|
1252
|
+
} else {
|
|
1253
|
+
await this._postTurnOutcome(existingPP, msgChannel, finalResponse);
|
|
1254
|
+
this._resetIdleTimer(msgChannel);
|
|
1255
|
+
await this._queueTodoNudge(msgChannel, msg);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
} else if (existingPP.userStopped) {
|
|
1259
|
+
if (!existingPP.everPostedAnything) {
|
|
1260
|
+
await this._postStopNotice(msgChannel);
|
|
1261
|
+
}
|
|
1262
|
+
return;
|
|
1263
|
+
} else {
|
|
1264
|
+
// Process died mid-message — fall through to spawn a fresh one
|
|
1265
|
+
this._log(`Persistent process died, falling back to fresh spawn for ${msgChannel}`);
|
|
1004
1266
|
}
|
|
1005
|
-
return;
|
|
1006
1267
|
}
|
|
1007
|
-
// Process died mid-message — fall through to spawn a fresh one
|
|
1008
|
-
this._log(`Persistent process died, falling back to fresh spawn for ${msgChannel}`);
|
|
1009
1268
|
}
|
|
1010
1269
|
|
|
1011
1270
|
let mcpConfigFile = null;
|
|
1012
1271
|
let cmd;
|
|
1013
1272
|
|
|
1014
|
-
|
|
1015
|
-
// `claude` think it's running under an SDK harness (org-scoped auth
|
|
1016
|
-
// path → 403). But preserve config vars the child needs for cloud
|
|
1017
|
-
// provider auth (Vertex, Bedrock) and model selection.
|
|
1018
|
-
const CLAUDE_ENV_KEEP = new Set([
|
|
1019
|
-
'CLAUDE_CODE_USE_VERTEX',
|
|
1020
|
-
'CLAUDE_CODE_USE_BEDROCK',
|
|
1021
|
-
'CLAUDE_MODEL',
|
|
1022
|
-
'CLAUDE_API_KEY',
|
|
1023
|
-
'CLAUDE_CODE_MAX_TURNS',
|
|
1024
|
-
]);
|
|
1025
|
-
const cleanEnv = { ...(this.agentEnv || process.env) };
|
|
1026
|
-
for (const k of Object.keys(cleanEnv)) {
|
|
1027
|
-
if ((k.startsWith('CLAUDE_') && !CLAUDE_ENV_KEEP.has(k)) || k === 'CLAUDECODE' || k === 'AI_AGENT') {
|
|
1028
|
-
delete cleanEnv[k];
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
// Third-party Anthropic-compatible relays (the common reason a custom
|
|
1033
|
-
// ANTHROPIC_BASE_URL is set) authenticate via `Authorization: Bearer`, which
|
|
1034
|
-
// the Claude CLI only sends when ANTHROPIC_AUTH_TOKEN is set. With just
|
|
1035
|
-
// ANTHROPIC_API_KEY the CLI sends `x-api-key`, which most relays ignore — the
|
|
1036
|
-
// relay then rejects every request as 401 "invalid token / 无效的令牌". When a
|
|
1037
|
-
// non-official base URL is configured and no auth token was provided, mirror
|
|
1038
|
-
// the API key into ANTHROPIC_AUTH_TOKEN (it outranks the API key in Claude
|
|
1039
|
-
// Code's auth precedence) so the CLI uses Bearer auth. The launcher normally
|
|
1040
|
-
// sets this when saving env; this is the runtime backstop for envs saved by
|
|
1041
|
-
// an older launcher or coming from any other source. The official
|
|
1042
|
-
// api.anthropic.com endpoint keeps x-api-key, so it is left untouched.
|
|
1043
|
-
const anthropicBase = (cleanEnv.ANTHROPIC_BASE_URL || '').trim();
|
|
1044
|
-
const anthropicKey = (cleanEnv.ANTHROPIC_API_KEY || '').trim();
|
|
1045
|
-
if (anthropicKey && anthropicBase && !(cleanEnv.ANTHROPIC_AUTH_TOKEN || '').trim()) {
|
|
1046
|
-
let officialAnthropic = false;
|
|
1047
|
-
try {
|
|
1048
|
-
const host = new URL(anthropicBase).hostname.toLowerCase();
|
|
1049
|
-
officialAnthropic = host === 'anthropic.com' || host.endsWith('.anthropic.com');
|
|
1050
|
-
} catch { officialAnthropic = false; }
|
|
1051
|
-
if (!officialAnthropic) {
|
|
1052
|
-
cleanEnv.ANTHROPIC_AUTH_TOKEN = anthropicKey;
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1273
|
+
const cleanEnv = ClaudeAdapter._buildChildEnv(this.agentEnv || process.env);
|
|
1055
1274
|
|
|
1056
1275
|
// Spawn a persistent process and send the first message via stdin
|
|
1057
1276
|
let effectiveContent = content;
|
|
@@ -1069,7 +1288,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1069
1288
|
if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
|
|
1070
1289
|
|
|
1071
1290
|
if (attempt > 0) {
|
|
1072
|
-
this._killPersistentProc(msgChannel);
|
|
1291
|
+
await this._killPersistentProc(msgChannel);
|
|
1073
1292
|
// Rebuild recap for retry without resume
|
|
1074
1293
|
try {
|
|
1075
1294
|
const recap = await this._buildChannelRecap(msgChannel, content);
|
|
@@ -1082,6 +1301,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1082
1301
|
const built = this._buildClaudeCmd(effectiveContent, msgChannel, {
|
|
1083
1302
|
skipResume: attempt > 0,
|
|
1084
1303
|
browserEnabled,
|
|
1304
|
+
decisionLog: decisionLogOpt,
|
|
1085
1305
|
});
|
|
1086
1306
|
cmd = built.cmd;
|
|
1087
1307
|
mcpConfigFile = built.mcpConfigFile;
|
|
@@ -1092,6 +1312,13 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1092
1312
|
|
|
1093
1313
|
try {
|
|
1094
1314
|
const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
|
|
1315
|
+
// Remember the spawn-time configuration so the fast-path can detect
|
|
1316
|
+
// staleness on later messages — the decision-log state and the mode
|
|
1317
|
+
// (which fixes both the system prompt and the permission flags).
|
|
1318
|
+
pp.decisionHash = decisionLogOpt
|
|
1319
|
+
? decisionFingerprint(decisionLogOpt.entryId, decisionLogOpt.content)
|
|
1320
|
+
: decisionFingerprint(null, null);
|
|
1321
|
+
pp.spawnMode = this._mode;
|
|
1095
1322
|
this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
|
|
1096
1323
|
|
|
1097
1324
|
const result = await this._sendToPersistentProc(pp, effectiveContent);
|
|
@@ -1108,6 +1335,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1108
1335
|
this._log(`Stale session detected, retrying without resume`);
|
|
1109
1336
|
delete this._channelSessions[msgChannel];
|
|
1110
1337
|
this._saveSessions();
|
|
1338
|
+
try {
|
|
1339
|
+
await this.sendStatus(
|
|
1340
|
+
msgChannel,
|
|
1341
|
+
'Claude session could not be resumed. Rebuilding context from the channel recap and pinned decisions.'
|
|
1342
|
+
);
|
|
1343
|
+
} catch {}
|
|
1111
1344
|
continue;
|
|
1112
1345
|
}
|
|
1113
1346
|
if (!pp.everPostedAnything) {
|
|
@@ -1121,70 +1354,15 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1121
1354
|
}
|
|
1122
1355
|
|
|
1123
1356
|
// Success — post final response
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
try {
|
|
1128
|
-
const planDir = path.join(this.workingDir || defaultAgentWorkdir(this.agentName), '.claude', 'plans');
|
|
1129
|
-
if (fs.existsSync(planDir)) {
|
|
1130
|
-
const planFiles = fs.readdirSync(planDir)
|
|
1131
|
-
.filter((f) => f.endsWith('.md'))
|
|
1132
|
-
.map((f) => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
|
|
1133
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
1134
|
-
if (planFiles.length > 0) {
|
|
1135
|
-
const planContent = fs.readFileSync(path.join(planDir, planFiles[0].name), 'utf-8').trim();
|
|
1136
|
-
if (planContent) pp.lastResponseText.push('\n\n---\n\n**Plan:**\n\n' + planContent);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
} catch {}
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
const finalResponse = pp.lastResponseText.join('\n').trim();
|
|
1143
|
-
if (/prompt is too long/i.test(finalResponse) && this._channelSessions[msgChannel]) {
|
|
1144
|
-
this._log(`Prompt too long, clearing session and retrying`);
|
|
1145
|
-
delete this._channelSessions[msgChannel];
|
|
1146
|
-
this._saveSessions();
|
|
1147
|
-
this._killPersistentProc(msgChannel);
|
|
1357
|
+
const finalResponse = this._composeFinalResponse(pp);
|
|
1358
|
+
if (this._isPromptTooLong(pp, finalResponse) && this._channelSessions[msgChannel]) {
|
|
1359
|
+
await this._resetSessionForPromptTooLong(msgChannel);
|
|
1148
1360
|
continue;
|
|
1149
1361
|
}
|
|
1150
1362
|
|
|
1151
|
-
|
|
1152
|
-
try { await this.sendResponse(msgChannel, finalResponse); } catch {}
|
|
1153
|
-
} else if (pp.lastErrorText) {
|
|
1154
|
-
// Claude finished with an error and no assistant text (e.g. 401 invalid
|
|
1155
|
-
// token). Surface it instead of silently dropping the turn.
|
|
1156
|
-
try { await this.sendError(msgChannel, this._formatClaudeError(pp.lastErrorText)); } catch {}
|
|
1157
|
-
} else if (!pp.everPostedAnything) {
|
|
1158
|
-
// Claude ended in error with NO assistant text AND an empty error
|
|
1159
|
-
// message (e.g. a fast code=1 crash while resuming a corrupt session).
|
|
1160
|
-
// Without this the turn is silently dropped and the UI hangs forever on
|
|
1161
|
-
// "thinking…". Clear the (possibly bad) session so the next message
|
|
1162
|
-
// starts fresh instead of re-resuming the same broken state, and tell
|
|
1163
|
-
// the user rather than leaving a dead spinner.
|
|
1164
|
-
if (this._channelSessions[msgChannel]) {
|
|
1165
|
-
delete this._channelSessions[msgChannel];
|
|
1166
|
-
try { this._saveSessions(); } catch {}
|
|
1167
|
-
this._log(`Empty-error result — cleared session for ${msgChannel}`);
|
|
1168
|
-
}
|
|
1169
|
-
try { await this.sendError(msgChannel, 'The agent hit an error and could not respond. The session was reset — please send the message again.'); } catch {}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1363
|
+
await this._postTurnOutcome(pp, msgChannel, finalResponse);
|
|
1172
1364
|
this._resetIdleTimer(msgChannel);
|
|
1173
|
-
|
|
1174
|
-
if (!msg._todoNudge) {
|
|
1175
|
-
try {
|
|
1176
|
-
const remaining = await this.getRemainingTodos(msgChannel);
|
|
1177
|
-
if (remaining.length > 0) {
|
|
1178
|
-
const items = remaining.map((t) => `- ${t.content}`).join('\n');
|
|
1179
|
-
const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
|
|
1180
|
-
if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
|
|
1181
|
-
this._channelQueues[msgChannel].push({
|
|
1182
|
-
content: nudge, senderType: 'system', senderName: 'system:todos',
|
|
1183
|
-
sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
|
-
} catch {}
|
|
1187
|
-
}
|
|
1365
|
+
await this._queueTodoNudge(msgChannel, msg);
|
|
1188
1366
|
break;
|
|
1189
1367
|
} catch (e) {
|
|
1190
1368
|
this._log(`Error handling message: ${e.message}`);
|