regent-code 3.0.5 → 3.0.7

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.
@@ -6,7 +6,7 @@ If the package is published to npm, both the plugin and the MCP server install
6
6
  with a single command against any existing `opencode.json` / `opencode.jsonc`:
7
7
 
8
8
  ```bash
9
- npx -y regent-code@3.0.5 install
9
+ npx -y regent-code@3.0.7 install
10
10
  ```
11
11
 
12
12
  Patches the project config (or the global `~/.config/opencode/` config) to add
@@ -19,7 +19,7 @@ Patches the project config (or the global `~/.config/opencode/` config) to add
19
19
  ```jsonc
20
20
  {
21
21
  "$schema": "https://opencode.ai/config.json",
22
- "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.5"],
22
+ "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.7"],
23
23
  }
24
24
  ```
25
25
 
@@ -32,7 +32,7 @@ Patches the project config (or the global `~/.config/opencode/` config) to add
32
32
  }
33
33
  ```
34
34
 
35
- The pinned version is recommended. Use the unpinned branch only when you intentionally want the latest changes. The `v3.0.5` git tag must be pushed to GitHub before the pinned spec resolves.
35
+ The pinned version is recommended. Use the unpinned branch only when you intentionally want the latest changes. The `v3.0.7` git tag must be pushed to GitHub before the pinned spec resolves.
36
36
 
37
37
  ## Single-source rule (duplicate plugin ID)
38
38
 
package/README.md CHANGED
@@ -51,7 +51,7 @@ Add Regent to your OpenCode configuration:
51
51
  ```jsonc
52
52
  {
53
53
  "$schema": "https://opencode.ai/config.json",
54
- "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.5"],
54
+ "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.7"],
55
55
  }
56
56
  ```
57
57
 
@@ -69,7 +69,7 @@ are read from the persisted transcript when the service stores turns, otherwise
69
69
  from the synchronous `session.generate` text; agent catalogs are queried with
70
70
  and without an explicit location scope; and the caller-authorization guardrail
71
71
  degrades OPEN when the runtime agent API is unresolvable (only recursion from
72
- known worker sessions stays hard-blocked). The `v3.0.5` git tag must be pushed
72
+ known worker sessions stays hard-blocked). The `v3.0.7` git tag must be pushed
73
73
  to GitHub before this pinned spec resolves.
74
74
 
75
75
  > **Windows dev-machine warning (single-source rule):** when this repository is open as an OpenCode project, its own `.opencode/plugins/regent.js` is auto-loaded as a project plugin. Do NOT also pin regent in `opencode.jsonc` on the same machine — two active sources make host plugin reloads fail with `Duplicate plugin ID: regent`, leaving sessions with a torn tool surface and blocking live skill/plugin edits. Either develop unpinned (project plugin only) or pin the repo file directly: `"plugins": ["file:///Q:/PROJECTS/PERSONAL/regent-code/.opencode/plugins/regent.js"]`. One source of truth, always.
@@ -79,13 +79,13 @@ to GitHub before this pinned spec resolves.
79
79
  The fastest way to get **both** the plugin and the MCP server on any machine — no cloning, no manual config edits, no local files:
80
80
 
81
81
  ```bash
82
- npx -y regent-code@3.0.5 install
82
+ npx -y regent-code@3.0.7 install
83
83
  ```
84
84
 
85
85
  The installer finds an existing `opencode.json` / `opencode.jsonc` (project config in the current directory first, then the global `~/.config/opencode/` config) and adds both entries:
86
86
 
87
87
  - **MCP server**: `mcp.servers.regent` → runs `["npx", "-y", "regent-code"]`. That single command spawns the server through the package's `bin` — no absolute paths, no global install, no per-machine shims.
88
- - **Plugin**: `plugins` → `regent-code@3.0.5`
88
+ - **Plugin**: `plugins` → `regent-code@3.0.7`
89
89
 
90
90
  It is **idempotent and non-destructive** — it only adds or updates regent entries, preserving comments, trailing commas, and every unrelated setting in the file. Re-run it to upgrade the pinned version. Flags: `--global` forces the user config, `--file <path>` targets an exact file, `--help` explains all options. Restart the OpenCode session afterwards — plugin load and MCP connection happen on config load.
91
91
 
package/mcp/index.js CHANGED
@@ -176,18 +176,28 @@ async function createWorkerResolver(client, options = {}) {
176
176
  }
177
177
 
178
178
  const candidates = [];
179
+ // Built-in subagents are last-resort fallbacks; project-defined and
180
+ // third-party child-capable agents are the preferred workers.
181
+ const BUILTIN_SUBAGENTS = new Set(['general', 'explore']);
179
182
  if (isChildCapableAgent(catalog.find((agent) => agent.id === 'regent-general'))) {
180
183
  candidates.push('regent-general');
181
184
  }
182
- const configuredGeneral = catalog.find((agent) => agent.id === 'general');
183
- if (!configuredGeneral || isChildCapableAgent(configuredGeneral)) {
184
- candidates.push('general');
185
- }
186
185
  for (const agent of catalog) {
187
- if (isChildCapableAgent(agent) && !candidates.includes(agent.id)) {
186
+ if (
187
+ isChildCapableAgent(agent) &&
188
+ !BUILTIN_SUBAGENTS.has(agent.id) &&
189
+ !candidates.includes(agent.id)
190
+ ) {
188
191
  candidates.push(agent.id);
189
192
  }
190
193
  }
194
+ if (isChildCapableAgent(catalog.find((agent) => agent.id === 'explore'))) {
195
+ candidates.push('explore');
196
+ }
197
+ const configuredGeneral = catalog.find((agent) => agent.id === 'general');
198
+ if (!configuredGeneral || isChildCapableAgent(configuredGeneral)) {
199
+ candidates.push('general');
200
+ }
191
201
 
192
202
  if (candidates.length === 0) {
193
203
  return { ok: false, error: 'No visible child-capable worker agent is available' };
@@ -227,7 +237,11 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
227
237
  }
228
238
 
229
239
  const key = 'mcp';
230
- const directory = process.cwd();
240
+ // Worker sessions attach to the service's CURRENT project by default —
241
+ // explicit foreign locations resolve to unloaded scopes with empty agent
242
+ // catalogs ("Agent not found"). Override only via REGENT_WORKER_LOCATION.
243
+ const workerLocation = (process.env.REGENT_WORKER_LOCATION || '').trim();
244
+ const directory = workerLocation || process.cwd();
231
245
  let session;
232
246
 
233
247
  try {
@@ -243,7 +257,8 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
243
257
  for (let index = 0; index < selection.agents.length; index++) {
244
258
  const agent = selection.agents[index];
245
259
  try {
246
- const createInput = { title, agent, location: { directory } };
260
+ const createInput = { title, agent };
261
+ if (workerLocation) createInput.location = { directory: workerLocation };
247
262
  const sessionResult = await withRetry(() => client.session.create(createInput));
248
263
  session = unwrapData(sessionResult);
249
264
  if (session?.id) break;
@@ -281,10 +296,18 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
281
296
  'If you cannot complete the task, say BLOCKED and explain why.',
282
297
  ].join('\n');
283
298
 
284
- const result = await withRetry(() =>
285
- client.session.generate({ sessionID: session.id, prompt }),
286
- );
287
- const seed = extractGenerationText(unwrapData(result));
299
+ // Dispatch the turn as a real user message so the agent executes with its
300
+ // tools and permissions. `session.generate` on current services is a
301
+ // sessionless text completion (no agent run, zero tokens), so it is only
302
+ // the fallback for older services that expose generate-only dispatch.
303
+ let result = null;
304
+ try {
305
+ await withRetry(() => client.session.prompt({ sessionID: session.id, text: prompt }));
306
+ } catch (err) {
307
+ if (isUnavailableAgentError(err)) throw err;
308
+ result = await withRetry(() => client.session.generate({ sessionID: session.id, prompt }));
309
+ }
310
+ const seed = result ? extractGenerationText(unwrapData(result)) : '';
288
311
 
289
312
  // Collect the final answer once the turn has actually ended (generate
290
313
  // resolves with the first chunk on current service semantics).
@@ -307,7 +330,21 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
307
330
  };
308
331
  }
309
332
  const parsed = parseSubagentTextResponse(output);
310
- const { status, concerns, filesChanged } = parsed;
333
+ let { status, concerns, filesChanged } = parsed;
334
+
335
+ // The completion ceremony (- summary/- status lines) is a soft contract:
336
+ // a wait-verified ended turn with a real answer that skipped it is still
337
+ // done. NEVER applied to explicit NEEDS_CONTEXT/BLOCKED markers or
338
+ // concerns; mid-turn fragments never reach here (they return empty and
339
+ // hit the blocked path above).
340
+ if (
341
+ status === 'needs_context' &&
342
+ concerns.length === 0 &&
343
+ !/\bNEEDS_CONTEXT\b/i.test(output) &&
344
+ output.trim()
345
+ ) {
346
+ status = 'done';
347
+ }
311
348
 
312
349
  if (filesChanged.length > 0) {
313
350
  sessionFileChanges.set(session.id, {
@@ -319,6 +356,9 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
319
356
  recordEvidence(session.id, filesChanged, directory);
320
357
  }
321
358
 
359
+ if (process.env.REGENT_TRACE_TURN === '1') {
360
+ console.error(`[trace] dispatch returning ${session.id.slice(-8)}`);
361
+ }
322
362
  return { status, output, concerns, files_changed: filesChanged, session_id: session.id };
323
363
  } catch (err) {
324
364
  const message = redactSecrets(safeErrorMessage(err)).slice(0, 500);
@@ -634,7 +674,7 @@ export function createRegentServer() {
634
674
  const client = await getClient();
635
675
  const resolveWorker = await createWorkerResolver(client, {
636
676
  workerAgent: process.env.REGENT_WORKER_AGENT,
637
- location: process.cwd(),
677
+ location: process.env.REGENT_WORKER_LOCATION || '',
638
678
  });
639
679
  const result = await dispatchSubagent(client, resolveWorker, /** @type {any} */ (args));
640
680
  return toContent(result);
@@ -659,7 +699,7 @@ export function createRegentServer() {
659
699
  const client = await getClient();
660
700
  const resolveWorker = await createWorkerResolver(client, {
661
701
  workerAgent: process.env.REGENT_WORKER_AGENT,
662
- location: process.cwd(),
702
+ location: process.env.REGENT_WORKER_LOCATION || '',
663
703
  });
664
704
  const queue = [...args.tasks];
665
705
  const results = [];
@@ -679,6 +719,9 @@ export function createRegentServer() {
679
719
  }
680
720
  const workerCount = Math.min(queue.length, 10);
681
721
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
722
+ if (process.env.REGENT_TRACE_TURN === '1') {
723
+ console.error(`[trace] all workers done (${results.length} results)`);
724
+ }
682
725
  const failed = results.filter((r) => r.status === 'blocked').length;
683
726
  recordCircuitResult('mcp', results.length > 0 && failed === 0);
684
727
  return toContent({
@@ -704,7 +747,7 @@ export function createRegentServer() {
704
747
  const client = await getClient();
705
748
  const resolveWorker = await createWorkerResolver(client, {
706
749
  workerAgent: process.env.REGENT_WORKER_AGENT,
707
- location: process.cwd(),
750
+ location: process.env.REGENT_WORKER_LOCATION || '',
708
751
  });
709
752
  const results = await Promise.all(
710
753
  args.questions.map(async (q) => {
package/mcp/shared.js CHANGED
@@ -342,10 +342,23 @@ export function extractGenerationText(result) {
342
342
  return '';
343
343
  }
344
344
 
345
+ /**
346
+ * A message is still streaming when the service stamps `time.streamed` but
347
+ * no `time.completed` yet. Mid-stream reads must never be treated as final.
348
+ * @param {any} message
349
+ * @returns {boolean}
350
+ */
351
+ function isStillStreaming(message) {
352
+ const time = message?.time;
353
+ if (!time || time.completed != null) return false;
354
+ return time.streamed != null;
355
+ }
356
+
345
357
  /**
346
358
  * Join the assistant text of a session transcript (SessionMessageInfo[]).
347
- * Only `type: "assistant"` messages contribute; reasoning/tool parts are
348
- * skipped. Handles both chronological and reverse (desc) orderings.
359
+ * Only `type: "assistant"` messages that finished streaming contribute;
360
+ * reasoning/tool parts are skipped. Handles chronological and reverse (desc)
361
+ * orderings.
349
362
  * @param {any[]} messages
350
363
  * @returns {string}
351
364
  */
@@ -354,6 +367,7 @@ export function joinAssistantText(messages) {
354
367
  const chunks = [];
355
368
  for (const message of messages) {
356
369
  if (!message || message.type !== 'assistant') continue;
370
+ if (isStillStreaming(message)) continue;
357
371
  const content = message.content;
358
372
  if (typeof content === 'string') {
359
373
  chunks.push(content);
@@ -414,13 +428,15 @@ export async function readTurnTranscript(sessionApi, sessionID) {
414
428
  return [];
415
429
  }
416
430
 
431
+ const COMPLETION_BLOCK = /(?:^|\n)\s*-\s*status\s*[:=]|\bBLOCKED\b|\bsummary\s*[:=]/i;
432
+
417
433
  /**
418
- * Wait for a dispatched turn to end. The service resolves `session.generate`
419
- * with the FIRST text chunk while the turn continues asynchronously, so the
420
- * final answer only becomes read-consistent once the turn settles. Completion
421
- * is signalled by `session.wait` (blocks until the session is idle) when the
422
- * client exposes it; otherwise by transcript settlement (two consecutive
423
- * identical non-empty reads). Bounded by timeoutMs; never hangs.
434
+ * Wait for a dispatched turn to end by polling the transcript. Session waits
435
+ * are deliberately NOT used: long-poll `wait` calls accumulating on the
436
+ * service stalled the server's response path (HTTP/1.1 head-of-line
437
+ * blocking). Turns complete in seconds; a transcript read that carries the
438
+ * completion block ends immediately, and two consecutive identical non-empty
439
+ * reads settle the no-ceremony case. Bounded by timeoutMs; never hangs.
424
440
  * @param {any} sessionApi session or client handle
425
441
  * @param {string} sessionID
426
442
  * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
@@ -429,26 +445,22 @@ export async function readTurnTranscript(sessionApi, sessionID) {
429
445
  export async function waitForTurnEnd(
430
446
  sessionApi,
431
447
  sessionID,
432
- { timeoutMs = 120000, intervalMs = 1500 } = {},
448
+ { timeoutMs = 120000, intervalMs = 1200 } = {},
433
449
  ) {
434
- if (typeof sessionApi?.session?.wait === 'function') {
435
- try {
436
- await Promise.race([
437
- sessionApi.session.wait({ sessionID }),
438
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
439
- ]);
440
- return;
441
- } catch {
442
- /* fall through to transcript settlement */
443
- }
444
- }
445
450
  const deadline = Date.now() + timeoutMs;
446
451
  let previous = '';
447
452
  let stableReads = 0;
448
453
  while (Date.now() < deadline) {
449
454
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
450
455
  const text = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
451
- if (text && text === previous) {
456
+ if (process.env.REGENT_TRACE_TURN === '1') {
457
+ console.error(
458
+ `[turn-trace] ${sessionID.slice(-8)} iter=${stableReads} text=${JSON.stringify(text.slice(0, 60))}`,
459
+ );
460
+ }
461
+ if (!text) continue;
462
+ if (COMPLETION_BLOCK.test(text)) return;
463
+ if (text === previous) {
452
464
  stableReads += 1;
453
465
  if (stableReads >= 2) return;
454
466
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "regent-code",
3
- "version": "3.0.5",
3
+ "version": "3.0.7",
4
4
  "description": "Agent orchestration for OpenCode. From idea to shipped — zero ceremony. Plugin + MCP server.",
5
5
  "type": "module",
6
6
  "main": ".opencode/plugins/regent.js",
Binary file
Binary file