@openagents-org/agent-launcher 0.2.193 → 0.2.194

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.
@@ -22,7 +22,7 @@ const { spawn } = require('../wsl');
22
22
 
23
23
  const BaseAdapter = require('./base');
24
24
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle, redactSecrets } = require('./utils');
25
- const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt');
25
+ const { buildClaudeSystemPrompt, buildClaudeSkillMd, buildPinnedSections, workspaceSkillName } = require('./workspace-prompt');
26
26
  const { pinnedFingerprint, sampleRecap } = require('./decision-log');
27
27
  const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
28
28
 
@@ -657,7 +657,12 @@ class ClaudeAdapter extends BaseAdapter {
657
657
  type: 'stdio',
658
658
  command: mcpCommand,
659
659
  args: mcpFinalArgs,
660
- env: { OA_WORKSPACE_TOKEN: this.token },
660
+ env: {
661
+ OA_WORKSPACE_TOKEN: this.token,
662
+ // v1.1: who asked, so escalation tools can address the owner.
663
+ ...(this.requesterEmailFor && this.requesterEmailFor(channelName)
664
+ ? { OPENAGENTS_REQUESTER_EMAIL: this.requesterEmailFor(channelName) } : {}),
665
+ },
661
666
  },
662
667
  },
663
668
  };
@@ -822,6 +827,11 @@ class ClaudeAdapter extends BaseAdapter {
822
827
  watchdogTimer: null,
823
828
  awaitingToolResult: false,
824
829
  userStopped: false,
830
+ // Background tasks (run_in_background Bash, background sub-agents) the
831
+ // CLI reports as still running, from its `background_tasks_changed`
832
+ // events. Killing the process kills them too, and a plan nudge sent
833
+ // while they run makes the agent re-announce work it is waiting on.
834
+ backgroundTasks: 0,
825
835
  };
826
836
 
827
837
  if (proc.stderr) {
@@ -928,10 +938,19 @@ class ClaudeAdapter extends BaseAdapter {
928
938
  try { await this.sendResponse(pp.msgChannel, trailing); } catch {}
929
939
  }
930
940
  pp.lastResponseText.length = 0;
941
+ // The plan nudge was held back while background work ran (see
942
+ // _queueTodoNudge); once the last task has reported, pick it up.
943
+ if (!event.is_error && pp.backgroundTasks === 0) {
944
+ await this._resumePlanAfterBackground(pp.msgChannel);
945
+ }
931
946
  }
932
947
  } else if (eventType === 'system') {
933
948
  const subtype = event.subtype || '';
934
949
  const message = event.message || '';
950
+ if (subtype === 'background_tasks_changed') {
951
+ pp.backgroundTasks = Array.isArray(event.tasks) ? event.tasks.length : 0;
952
+ return;
953
+ }
935
954
  if (subtype.includes('compact') || String(message).toLowerCase().includes('compact')) {
936
955
  await this.sendStatus(pp.msgChannel, String(message) || 'Compacting conversation...');
937
956
  }
@@ -1122,22 +1141,109 @@ class ClaudeAdapter extends BaseAdapter {
1122
1141
  /** Queue a reminder about unfinished todos (skipped when this turn IS one). */
1123
1142
  async _queueTodoNudge(msgChannel, msg) {
1124
1143
  if (msg._todoNudge) return;
1144
+ const nudge = await this._buildTodoNudge(msgChannel);
1145
+ if (!nudge) return;
1146
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
1147
+ if (this._channelQueues[msgChannel].some((m) => m._todoNudge)) return;
1148
+ this._channelQueues[msgChannel].push(nudge);
1149
+ }
1150
+
1151
+ /**
1152
+ * The "you have N remaining tasks" message, or null when there is nothing
1153
+ * to nudge: no unfinished todos, a stop in progress, or background work
1154
+ * still running in this channel's process (the agent has said it is waiting
1155
+ * on it; nudging made it re-announce the plan — and, combined with a
1156
+ * restart, re-launch work that had been killed). The nudge is picked up
1157
+ * again once the background tasks report (_resumePlanAfterBackground).
1158
+ */
1159
+ async _buildTodoNudge(msgChannel) {
1125
1160
  // A stop ends the plan, it does not pause it. When the stop lands while
1126
1161
  // this turn is finishing, nudging here is what handed the agent its own
1127
1162
  // "please continue" right after it announced it had stopped.
1128
- if (this._stoppingChannels.has(msgChannel)) return;
1163
+ if (this._stoppingChannels.has(msgChannel)) return null;
1164
+ const pp = this._persistentProcs[msgChannel];
1165
+ if (pp && pp.alive && pp.backgroundTasks > 0) {
1166
+ this._log(`Holding the plan nudge for ${msgChannel} — ${pp.backgroundTasks} background task(s) still running`);
1167
+ return null;
1168
+ }
1129
1169
  try {
1130
1170
  const remaining = await this.getRemainingTodos(msgChannel);
1131
- if (remaining.length > 0) {
1132
- const items = remaining.map((t) => `- ${t.content}`).join('\n');
1133
- const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
1134
- if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
1135
- this._channelQueues[msgChannel].push({
1136
- content: nudge, senderType: 'system', senderName: 'system:todos',
1137
- sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
1138
- });
1139
- }
1140
- } catch {}
1171
+ if (remaining.length === 0) return null;
1172
+ const items = remaining.map((t) => `- ${t.content}`).join('\n');
1173
+ return {
1174
+ content: `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`,
1175
+ senderType: 'system', senderName: 'system:todos',
1176
+ sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
1177
+ };
1178
+ } catch {
1179
+ return null;
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * Background work finished and the CLI wrapped it up on its own. If the
1185
+ * plan still has open items, continue it now — queued behind a running
1186
+ * turn, or as its own turn when the channel is idle.
1187
+ */
1188
+ async _resumePlanAfterBackground(msgChannel) {
1189
+ const nudge = await this._buildTodoNudge(msgChannel);
1190
+ if (!nudge) return;
1191
+ if (this._channelBusy.has(msgChannel)) {
1192
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
1193
+ if (!this._channelQueues[msgChannel].some((m) => m._todoNudge)) this._channelQueues[msgChannel].push(nudge);
1194
+ return;
1195
+ }
1196
+ this._channelWorker(msgChannel, nudge);
1197
+ }
1198
+
1199
+ /**
1200
+ * The pinned decision log / glossary, rendered exactly as in the system
1201
+ * prompt, framed as replacing the copy the running session started with.
1202
+ * Returns '' when nothing is pinned.
1203
+ */
1204
+ _pinnedUpdatePreamble(msgChannel, decisionLog, glossary) {
1205
+ const sections = buildPinnedSections({
1206
+ toolMode: this.toolMode,
1207
+ channelName: msgChannel,
1208
+ mode: this._mode,
1209
+ decisionLog,
1210
+ glossary,
1211
+ }).join('').trim();
1212
+ if (!sections) return '';
1213
+ return '[Pinned knowledge was updated since this session started. The ' +
1214
+ 'sections below replace the earlier versions in your instructions — ' +
1215
+ 'treat them as current.]\n\n' + sections;
1216
+ }
1217
+
1218
+ /**
1219
+ * Wait for the next `result` on an already-addressed process without
1220
+ * writing anything to it. If the process stays completely silent for
1221
+ * `quietMs` there is no second turn coming; resolve with { quiet: true } so
1222
+ * the caller falls back to the ordinary empty-outcome handling. Once output
1223
+ * starts, the regular watchdog takes over.
1224
+ */
1225
+ _awaitNextTurn(pp, quietMs = 20_000) {
1226
+ const startedAt = Date.now();
1227
+ return new Promise((resolve) => {
1228
+ let quietTimer = null;
1229
+ const finish = (r) => {
1230
+ if (quietTimer) { clearInterval(quietTimer); quietTimer = null; }
1231
+ this._stopWatchdog(pp);
1232
+ resolve(r);
1233
+ };
1234
+ pp.messageResolve = finish;
1235
+ quietTimer = setInterval(() => {
1236
+ if (pp.lastStdoutTime > startedAt) {
1237
+ clearInterval(quietTimer);
1238
+ quietTimer = null;
1239
+ this._startWatchdog(pp);
1240
+ } else if (Date.now() - startedAt >= quietMs) {
1241
+ pp.messageResolve = null;
1242
+ finish({ quiet: true });
1243
+ }
1244
+ }, 500);
1245
+ if (quietTimer.unref) quietTimer.unref();
1246
+ });
1141
1247
  }
1142
1248
 
1143
1249
  async _handleMessage(msg) {
@@ -1217,22 +1323,34 @@ class ClaudeAdapter extends BaseAdapter {
1217
1323
  const modeStale = existingPP.spawnMode !== this._mode;
1218
1324
  const pinnedStale = pinnedHash !== null && existingPP.pinnedHash !== pinnedHash;
1219
1325
  const modelStale = (existingPP.spawnModel || null) !== (this.workspaceModel || null);
1220
- if (modeStale || pinnedStale || modelStale) {
1326
+ if (modeStale || modelStale) {
1221
1327
  // Kill it and fall through to a fresh spawn: --resume keeps the
1222
1328
  // conversation (it lives in the CLI transcript), while the new spawn
1223
- // carries the current mode and re-pins the current knowledge.
1329
+ // carries the current mode / model flags (and the current pins).
1224
1330
  this._log(modeStale
1225
1331
  ? `Mode changed to ${this._mode} for ${msgChannel} — respawning with resume`
1226
- : modelStale
1227
- ? `Model changed to ${this.workspaceModel || '(default)'} for ${msgChannel} — respawning with resume`
1228
- : `Pinned knowledge changed for ${msgChannel} — respawning with resume to re-pin it`);
1332
+ : `Model changed to ${this.workspaceModel || '(default)'} for ${msgChannel} — respawning with resume`);
1229
1333
  await this._killPersistentProc(msgChannel);
1230
1334
  } else {
1231
- this._log(`Reusing persistent process for ${msgChannel}`);
1335
+ // A pinned-knowledge change no longer restarts the process. Agents
1336
+ // edit the decision log themselves on nearly every decision, and the
1337
+ // restart killed whatever they had running in the background (a
1338
+ // sub-agent mid-build) and replayed into a false "agent hit an error".
1339
+ // The new version rides in with this message instead and supersedes
1340
+ // the copy baked into the system prompt.
1341
+ let turnContent = content;
1342
+ if (pinnedStale) {
1343
+ const update = this._pinnedUpdatePreamble(msgChannel, decisionLogOpt, glossaryOpt);
1344
+ if (update) turnContent = `${update}\n\n---\n\n${content}`;
1345
+ existingPP.pinnedHash = pinnedHash;
1346
+ this._log(`Pinned knowledge changed for ${msgChannel} — passing the update to the running session`);
1347
+ } else {
1348
+ this._log(`Reusing persistent process for ${msgChannel}`);
1349
+ }
1232
1350
  if (await this._bailOnStopDuringTurn(msgChannel)) return;
1233
1351
  this._resetIdleTimer(msgChannel);
1234
1352
  existingPP.msgChannel = msgChannel;
1235
- const result = await this._sendToPersistentProc(existingPP, content);
1353
+ const result = await this._sendToPersistentProc(existingPP, turnContent);
1236
1354
  if (existingPP.userStopped) {
1237
1355
  await this._finishUserStop(msgChannel);
1238
1356
  return;
@@ -1314,9 +1432,22 @@ class ClaudeAdapter extends BaseAdapter {
1314
1432
  ]);
1315
1433
  pp.spawnMode = this._mode;
1316
1434
  pp.spawnModel = this.workspaceModel || null;
1435
+ const resumed = attempt === 0 && !!this._channelSessions[msgChannel];
1317
1436
  this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
1318
1437
 
1319
- const result = await this._sendToPersistentProc(pp, effectiveContent);
1438
+ let result = await this._sendToPersistentProc(pp, effectiveContent);
1439
+
1440
+ // A resumed session can first report something left over from before
1441
+ // the restart — typically a background task that died with the old
1442
+ // process — as a turn with no text and no error. That is not this
1443
+ // message's answer: wait for the real turn instead of posting "the
1444
+ // agent hit an error" and throwing the session away.
1445
+ if (resumed && result.resultEvent && !pp.userStopped
1446
+ && !pp.everPostedAnything && !pp.lastErrorText
1447
+ && !this._composeFinalResponse(pp)) {
1448
+ this._log(`Empty leftover result after resume for ${msgChannel} — waiting for the real turn`);
1449
+ result = await this._awaitNextTurn(pp);
1450
+ }
1320
1451
 
1321
1452
  if (pp.userStopped) {
1322
1453
  await this._finishUserStop(msgChannel);
@@ -79,10 +79,27 @@ class CodexAdapter extends BaseAdapter {
79
79
  const isOpenAiNative = !this._directBaseUrl ||
80
80
  this._directBaseUrl.includes('api.openai.com');
81
81
 
82
- if (this._codexBin && (isOpenAiNative || !this._directApiKey)) {
83
- // CLI mode: either OpenAI native API or subscription auth (no API key)
82
+ // A third-party endpoint that speaks the Responses API can drive the
83
+ // real CLI (tools, sandbox, resume) instead of the tool-less direct
84
+ // mode. Opt in with CODEX_WIRE_API=responses; the provider is passed to
85
+ // codex as `-c` overrides, so nothing is written to ~/.codex/config.toml.
86
+ this._customProvider = null;
87
+ if (this._codexBin && !isOpenAiNative && this._directApiKey &&
88
+ String(env.CODEX_WIRE_API || '').toLowerCase() === 'responses') {
89
+ this._customProvider = {
90
+ baseUrl: this._directBaseUrl,
91
+ reasoningEffort: String(env.CODEX_REASONING_EFFORT || '').toLowerCase() || null,
92
+ };
93
+ }
94
+
95
+ if (this._codexBin && (isOpenAiNative || !this._directApiKey || this._customProvider)) {
96
+ // CLI mode: OpenAI native API, subscription auth (no API key), or a
97
+ // Responses-compatible custom provider.
84
98
  this._useCliMode = true;
85
- this._log(`CLI mode: ${this._codexBin}${!this._directApiKey ? ' (subscription auth)' : ''}`);
99
+ const how = this._customProvider
100
+ ? ` (custom Responses provider ${this._customProvider.baseUrl})`
101
+ : (!this._directApiKey ? ' (subscription auth)' : '');
102
+ this._log(`CLI mode: ${this._codexBin}${how}`);
86
103
  } else if (this._directApiKey && this._directBaseUrl) {
87
104
  this._directMode = true;
88
105
  if (this._codexBin) {
@@ -319,6 +336,23 @@ class CodexAdapter extends BaseAdapter {
319
336
  cmd.push('-m', effectiveModel);
320
337
  }
321
338
 
339
+ // Custom Responses-API provider (see constructor). TOML values for
340
+ // `-c` must be quoted; the key itself travels in env (env_key), never
341
+ // on the command line.
342
+ if (this._customProvider) {
343
+ const q = (v) => JSON.stringify(String(v));
344
+ cmd.push(
345
+ '-c', 'model_provider=openagents',
346
+ '-c', `model_providers.openagents.name=${q('openagents')}`,
347
+ '-c', `model_providers.openagents.base_url=${q(this._customProvider.baseUrl)}`,
348
+ '-c', `model_providers.openagents.env_key=${q('OPENAI_API_KEY')}`,
349
+ '-c', `model_providers.openagents.wire_api=${q('responses')}`,
350
+ );
351
+ if (this._customProvider.reasoningEffort) {
352
+ cmd.push('-c', `model_reasoning_effort=${q(this._customProvider.reasoningEffort)}`);
353
+ }
354
+ }
355
+
322
356
  // Working directory
323
357
  if (this.workingDir) {
324
358
  cmd.push('-C', this.workingDir);
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where Cursor's Windows launcher keeps the node it runs. Shared by the
5
+ * adapter (to start a turn) and the installer (to ask `cursor-agent status`),
6
+ * so neither goes through cmd.exe and PowerShell.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ /** A Cursor version directory: YYYY.MM.DD-commit, or YYYY.MM.DD-HH-MM-SS-commit. */
13
+ const CURSOR_VERSION_DIR = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(-\d{2}-\d{2}-\d{2})?-[a-f0-9]+$/;
14
+
15
+ /** The node.exe + index.js pair in one install root (beside it, or its newest version). */
16
+ function entryUnder(dir) {
17
+ const pair = (d) => {
18
+ const node = path.join(d, 'node.exe');
19
+ const entry = path.join(d, 'index.js');
20
+ return fs.existsSync(node) && fs.existsSync(entry) ? [node, entry] : null;
21
+ };
22
+ const beside = pair(dir);
23
+ if (beside) return beside;
24
+
25
+ let names;
26
+ try { names = fs.readdirSync(path.join(dir, 'versions')); } catch { return null; }
27
+ const day = (name) => {
28
+ const m = name.match(CURSOR_VERSION_DIR);
29
+ return Number(m[1] + m[2].padStart(2, '0') + m[3].padStart(2, '0'));
30
+ };
31
+ const newestFirst = names
32
+ .filter((name) => CURSOR_VERSION_DIR.test(name))
33
+ .sort((a, b) => day(b) - day(a) || (a < b ? 1 : -1));
34
+ for (const name of newestFirst) {
35
+ const found = pair(path.join(dir, 'versions', name));
36
+ if (found) return found;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * The `node.exe` + `index.js` pair behind Cursor's Windows launcher, or null.
43
+ *
44
+ * The Windows install is not an npm shim. cursor-agent.cmd hands its arguments
45
+ * to PowerShell, and cursor-agent.ps1 runs the node.exe shipped in the newest
46
+ * `versions\<version>` directory (or beside the script) on that directory's
47
+ * index.js. This is the same lookup, so the caller can start node itself.
48
+ *
49
+ * The launcher found on PATH is not always inside the install (a copy or link
50
+ * in ~/.local/bin, say), so the installer's default root,
51
+ * %LOCALAPPDATA%\cursor-agent, is tried next.
52
+ */
53
+ function resolveCursorNodeEntry(binPath, env = process.env) {
54
+ const dir = path.dirname(path.resolve(binPath));
55
+ const found = entryUnder(dir);
56
+ if (found) return found;
57
+ const localAppData = env && env.LOCALAPPDATA;
58
+ if (!localAppData) return null;
59
+ const root = path.join(localAppData, 'cursor-agent');
60
+ if (path.resolve(root) === dir) return null;
61
+ return entryUnder(root);
62
+ }
63
+
64
+ module.exports = { resolveCursorNodeEntry, CURSOR_VERSION_DIR };
@@ -22,6 +22,7 @@ const BaseAdapter = require('./base');
22
22
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
23
23
  const { buildCursorSkillMd, workspaceSkillName } = require('./workspace-prompt');
24
24
  const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
25
+ const { resolveCursorNodeEntry } = require('./cursor-node-entry');
25
26
 
26
27
  const IS_WINDOWS = process.platform === 'win32';
27
28
 
@@ -267,6 +268,18 @@ class CursorAdapter extends BaseAdapter {
267
268
  }
268
269
 
269
270
  _resolveToNodeCmd(binPath) {
271
+ // Cursor's own launcher first. Left to the `cmd.exe /c` fallback below, the
272
+ // prompt goes through cmd.exe and PowerShell before it reaches node, and
273
+ // cmd.exe stops reading a command line at its first newline. Every prompt
274
+ // carries one (the identity header ends in a blank line), so the message
275
+ // itself and every flag after it — --output-format, --trust, --force,
276
+ // --model, --workspace, --resume — were dropped. The CLI then ran the
277
+ // header alone, untrusted, and exited with "Workspace Trust Required",
278
+ // which reached the chat as "No response generated".
279
+ if (IS_WINDOWS) {
280
+ const own = resolveCursorNodeEntry(binPath);
281
+ if (own) return own;
282
+ }
270
283
  const nodeBin = this._findNodeBin();
271
284
  if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
272
285
  const cmdDir = path.dirname(path.resolve(binPath));
@@ -440,14 +453,30 @@ class CursorAdapter extends BaseAdapter {
440
453
  if (this._stoppedBeforeStart(msgChannel)) return;
441
454
 
442
455
  try {
443
- const resolved = this._resolveToNodeCmd(cmd[0]);
456
+ const launcher = cmd[0];
457
+ const resolved = this._resolveToNodeCmd(launcher);
444
458
  if (resolved) {
445
459
  cmd = [...resolved, ...cmd.slice(1)];
446
460
  } else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
461
+ if (cmd.some((a) => typeof a === 'string' && a.includes('\n'))) {
462
+ this._log(
463
+ `Could not find Cursor's node.exe for ${launcher}; starting it through cmd.exe, `
464
+ + 'which stops reading at the first newline, so the prompt and the flags after it may be truncated',
465
+ 'warn',
466
+ );
467
+ }
447
468
  cmd = ['cmd.exe', '/c', ...cmd];
448
469
  }
449
470
 
450
471
  const cleanEnv = { ...(this.agentEnv || process.env) };
472
+ // Starting node directly skips Cursor's launcher script, so set what the
473
+ // script would have: its own name, and node's compile cache.
474
+ if (IS_WINDOWS && resolved) {
475
+ if (!cleanEnv.CURSOR_INVOKED_AS) cleanEnv.CURSOR_INVOKED_AS = path.basename(launcher);
476
+ if (!cleanEnv.NODE_COMPILE_CACHE && cleanEnv.LOCALAPPDATA) {
477
+ cleanEnv.NODE_COMPILE_CACHE = path.join(cleanEnv.LOCALAPPDATA, 'cursor-compile-cache');
478
+ }
479
+ }
451
480
 
452
481
  const proc = spawn(cmd[0], cmd.slice(1), {
453
482
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -680,3 +709,4 @@ function _extractToolDetail(tc) {
680
709
  }
681
710
 
682
711
  module.exports = CursorAdapter;
712
+ module.exports.resolveCursorNodeEntry = resolveCursorNodeEntry;