@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.
@@ -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 last chat exchanges, used to
229
- * re-seed context when --resume fails and we have to start a fresh
230
- * Claude Code session. Returns null when there's nothing useful to add.
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 for-loop will append it
233
- * normally) and any status/thinking events, which are mostly tool-call
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
- const messages = await this.client.getRecentMessages(
238
- this.workspaceId, channelName, this.token, 60
239
- );
240
- if (!messages || messages.length === 0) return null;
241
-
242
- const lines = [];
243
- for (const m of messages) {
244
- const mt = m.messageType || 'chat';
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 recent conversation for context:\n\n' +
261
- tail
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
- cmd.push('--disallowedTools', 'AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup');
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
- const skillFile = path.join(skillDir, 'openagents-workspace.md');
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
- delete this._persistentProcs[channel];
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
- delete this._persistentProcs[channel];
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
- this._log(`Reusing persistent process for ${msgChannel}`);
965
- this._resetIdleTimer(msgChannel);
966
- existingPP.msgChannel = msgChannel;
967
- const result = await this._sendToPersistentProc(existingPP, content);
968
- if (result.resultEvent) {
969
- const fullResponse = existingPP.lastResponseText.join('\n').trim();
970
- if (fullResponse) {
971
- try { await this.sendResponse(msgChannel, fullResponse); } catch {}
972
- } else if (existingPP.lastErrorText) {
973
- try { await this.sendError(msgChannel, this._formatClaudeError(existingPP.lastErrorText)); } catch {}
974
- } else if (!existingPP.everPostedAnything) {
975
- // Error with no text and no error message — don't leave the UI stuck
976
- // on "thinking…". Reset the (possibly corrupt) session and tell the user.
977
- if (this._channelSessions[msgChannel]) {
978
- delete this._channelSessions[msgChannel];
979
- try { this._saveSessions(); } catch {}
980
- this._log(`Empty-error result — cleared session for ${msgChannel}`);
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
- if (!msg._todoNudge) {
986
- try {
987
- const remaining = await this.getRemainingTodos(msgChannel);
988
- if (remaining.length > 0) {
989
- const items = remaining.map((t) => `- ${t.content}`).join('\n');
990
- const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
991
- if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
992
- this._channelQueues[msgChannel].push({
993
- content: nudge, senderType: 'system', senderName: 'system:todos',
994
- sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
995
- });
996
- }
997
- } catch {}
998
- }
999
- return;
1000
- }
1001
- if (existingPP.userStopped) {
1002
- if (!existingPP.everPostedAnything) {
1003
- await this._postStopNotice(msgChannel);
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
- // Clean env: strip CLAUDE_* / AI_AGENT variables that make the spawned
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 fullResponse = pp.lastResponseText.join('\n').trim();
1125
-
1126
- if (this._mode === 'plan') {
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
- if (finalResponse) {
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}`);