regent-code 3.0.4 → 3.0.6

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.4 install
9
+ npx -y regent-code@3.0.6 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.4"],
22
+ "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.6"],
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.4` 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.6` 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.4"],
54
+ "plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.6"],
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.4` git tag must be pushed
72
+ known worker sessions stays hard-blocked). The `v3.0.6` 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.4 install
82
+ npx -y regent-code@3.0.6 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.4`
88
+ - **Plugin**: `plugins` → `regent-code@3.0.6`
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
@@ -26,6 +26,7 @@ import {
26
26
  markEvidenceVerified,
27
27
  evidenceForScope,
28
28
  redactSecrets,
29
+ safeErrorMessage,
29
30
  isSensitiveFocusPath,
30
31
  parseSubagentTextResponse,
31
32
  unwrapData,
@@ -226,7 +227,11 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
226
227
  }
227
228
 
228
229
  const key = 'mcp';
229
- const directory = process.cwd();
230
+ // Worker sessions attach to the service's CURRENT project by default —
231
+ // explicit foreign locations resolve to unloaded scopes with empty agent
232
+ // catalogs ("Agent not found"). Override only via REGENT_WORKER_LOCATION.
233
+ const workerLocation = (process.env.REGENT_WORKER_LOCATION || '').trim();
234
+ const directory = workerLocation || process.cwd();
230
235
  let session;
231
236
 
232
237
  try {
@@ -242,7 +247,8 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
242
247
  for (let index = 0; index < selection.agents.length; index++) {
243
248
  const agent = selection.agents[index];
244
249
  try {
245
- const createInput = { title, agent, location: { directory } };
250
+ const createInput = { title, agent };
251
+ if (workerLocation) createInput.location = { directory: workerLocation };
246
252
  const sessionResult = await withRetry(() => client.session.create(createInput));
247
253
  session = unwrapData(sessionResult);
248
254
  if (session?.id) break;
@@ -285,9 +291,26 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
285
291
  );
286
292
  const seed = extractGenerationText(unwrapData(result));
287
293
 
288
- // Collect the final answer: persisted transcript wins, otherwise the
289
- // synchronous generation result is the answer.
290
- const output = await collectWorkerAnswer(client, session.id, seed);
294
+ // Collect the final answer once the turn has actually ended (generate
295
+ // resolves with the first chunk on current service semantics).
296
+ const output = await collectWorkerAnswer(client, session.id, seed, {
297
+ timeoutMs: Number(process.env.REGENT_TURN_TIMEOUT_MS) || 120000,
298
+ });
299
+ if (!output) {
300
+ try {
301
+ await client.session.interrupt({ sessionID: session.id });
302
+ } catch {
303
+ /* best effort */
304
+ }
305
+ return {
306
+ status: 'blocked',
307
+ output:
308
+ 'Subagent error: worker turn ended without producing an answer (stalled or timed out)',
309
+ concerns: [],
310
+ files_changed: [],
311
+ session_id: session.id,
312
+ };
313
+ }
291
314
  const parsed = parseSubagentTextResponse(output);
292
315
  const { status, concerns, filesChanged } = parsed;
293
316
 
@@ -303,7 +326,7 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
303
326
 
304
327
  return { status, output, concerns, files_changed: filesChanged, session_id: session.id };
305
328
  } catch (err) {
306
- const message = redactSecrets(err instanceof Error ? err.message : String(err)).slice(0, 500);
329
+ const message = redactSecrets(safeErrorMessage(err)).slice(0, 500);
307
330
  return {
308
331
  status: 'blocked',
309
332
  output: `Subagent error: ${message}`,
@@ -360,8 +383,11 @@ function runExplore(query, focus) {
360
383
  if (focus) {
361
384
  const worktreeRoot = path.resolve(worktree);
362
385
  const focusPath = path.resolve(worktree, focus);
386
+ const absoluteFocus = path.isAbsolute(focus);
387
+ // Absolute focus paths may point outside the project (e.g. another repo);
388
+ // the sensitive-path guards still apply to every segment of the path.
363
389
  const lexicalInside =
364
- focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot;
390
+ absoluteFocus || focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot;
365
391
  if (!lexicalInside) {
366
392
  result += `\n## Focus: ${focus}\n(path outside project directory)\n`;
367
393
  } else if (isSensitiveFocusPath(focusPath, worktreeRoot)) {
@@ -376,8 +402,9 @@ function runExplore(query, focus) {
376
402
  /* fall back to lexical paths */
377
403
  }
378
404
  const inside =
379
- (realFocus.startsWith(realRoot + path.sep) || realFocus === realRoot) &&
380
- (focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot);
405
+ absoluteFocus ||
406
+ ((realFocus.startsWith(realRoot + path.sep) || realFocus === realRoot) &&
407
+ (focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot));
381
408
  if (!inside) {
382
409
  result += `\n## Focus: ${focus}\n(path outside project directory)\n`;
383
410
  } else if (isSensitiveFocusPath(realFocus, realRoot)) {
@@ -612,7 +639,7 @@ export function createRegentServer() {
612
639
  const client = await getClient();
613
640
  const resolveWorker = await createWorkerResolver(client, {
614
641
  workerAgent: process.env.REGENT_WORKER_AGENT,
615
- location: process.cwd(),
642
+ location: process.env.REGENT_WORKER_LOCATION || '',
616
643
  });
617
644
  const result = await dispatchSubagent(client, resolveWorker, /** @type {any} */ (args));
618
645
  return toContent(result);
@@ -637,7 +664,7 @@ export function createRegentServer() {
637
664
  const client = await getClient();
638
665
  const resolveWorker = await createWorkerResolver(client, {
639
666
  workerAgent: process.env.REGENT_WORKER_AGENT,
640
- location: process.cwd(),
667
+ location: process.env.REGENT_WORKER_LOCATION || '',
641
668
  });
642
669
  const queue = [...args.tasks];
643
670
  const results = [];
@@ -682,7 +709,7 @@ export function createRegentServer() {
682
709
  const client = await getClient();
683
710
  const resolveWorker = await createWorkerResolver(client, {
684
711
  workerAgent: process.env.REGENT_WORKER_AGENT,
685
- location: process.cwd(),
712
+ location: process.env.REGENT_WORKER_LOCATION || '',
686
713
  });
687
714
  const results = await Promise.all(
688
715
  args.questions.map(async (q) => {
package/mcp/shared.js CHANGED
@@ -1,6 +1,18 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ // ── Error helpers ──
5
+ /** @param {unknown} err @returns {string} */
6
+ export function safeErrorMessage(err) {
7
+ const value = err instanceof Error ? err.message : err;
8
+ if (typeof value === 'string' && value.trim()) return value;
9
+ try {
10
+ return JSON.stringify(value);
11
+ } catch {
12
+ return String(value);
13
+ }
14
+ }
15
+
4
16
  // ── Dispatch safety limits ──
5
17
  export const MAX_DISPATCH_ITEMS = 10;
6
18
  export const MAX_STRING_LENGTH = 8000;
@@ -234,26 +246,46 @@ export function isSensitiveFocusPath(focusPath, worktreeRoot) {
234
246
  }
235
247
 
236
248
  // ── Subagent text-response parsing ──
249
+ const STATUS_WORDS = new Set(['done', 'blocked', 'needs_context', 'done_with_concerns']);
250
+
237
251
  /**
252
+ * Parse a worker's final answer. A status line the worker was asked to emit
253
+ * (`- status: <word>`) wins; legacy ALL-CAPS tokens are fallbacks. `done` is
254
+ * only claimed when the answer has completion shape (`summary` or an explicit
255
+ * status line) — a bare fragment such as a mid-turn first chunk parses as
256
+ * needs_context, never as a false done. Empty output parses as blocked.
238
257
  * @param {string} text
239
258
  * @returns {{ status: string, concerns: string[], filesChanged: string[] }}
240
259
  */
241
260
  export function parseSubagentTextResponse(text) {
242
- let status = 'done';
243
- let concerns = [];
261
+ const trimmed = String(text || '').trim();
262
+ if (!trimmed) {
263
+ return { status: 'blocked', concerns: ['worker returned no output'], filesChanged: [] };
264
+ }
244
265
 
245
- if (text.includes('BLOCKED')) {
266
+ const explicit = trimmed.match(/(?:^|\n)\s*-\s*status\s*[:=]\s*([a-z_]+)/i);
267
+ const statusWord = explicit ? explicit[1].toLowerCase() : '';
268
+ let status;
269
+ if (STATUS_WORDS.has(statusWord)) {
270
+ status = statusWord;
271
+ } else if (trimmed.includes('BLOCKED')) {
246
272
  status = 'blocked';
247
- } else if (text.includes('NEEDS_CONTEXT')) {
273
+ } else if (trimmed.includes('NEEDS_CONTEXT')) {
248
274
  status = 'needs_context';
249
- } else if (text.includes('CONCERN:')) {
275
+ } else if (trimmed.includes('CONCERN:')) {
250
276
  status = 'done_with_concerns';
251
- concerns = text.match(/CONCERN:.*$/gm)?.map((c) => c.replace('CONCERN:', '').trim()) || [];
277
+ } else if (/\bsummary\s*[:=]/i.test(trimmed)) {
278
+ status = 'done';
279
+ } else {
280
+ status = 'needs_context';
252
281
  }
253
282
 
283
+ const concerns =
284
+ trimmed.match(/CONCERN:.*$/gm)?.map((c) => c.replace('CONCERN:', '').trim()) || [];
285
+
254
286
  const pathPattern =
255
287
  /(?:^|\n)(?:[\w./\\-]+\.[a-zA-Z0-9]+|[\w.-]+(?:[\\/][\w.-]+)+(?:\.[a-zA-Z0-9]+)?|^[A-Za-z][\w-]+\.[\w-]+|^[A-Za-z][\w-]+(?:\.[\w-]+)*$(?!\.))/gm;
256
- const matches = text.match(pathPattern);
288
+ const matches = trimmed.match(pathPattern);
257
289
  const filesChanged = (matches || [])
258
290
  .map((f) => f.trim())
259
291
  .filter(
@@ -338,19 +370,33 @@ export function joinAssistantText(messages) {
338
370
 
339
371
  /**
340
372
  * Read the latest session transcript through whatever API the session handle
341
- * exposes: `message.list` (client) first, then `session.context` (plugin
342
- * domain), then nothing.
373
+ * exposes, newest contract first: `session.context` (promised client) and
374
+ * `message.list` (returns { data: SessionMessageInfo[], cursor }), then the
375
+ * legacy `session.context` plugin domain.
343
376
  * @param {any} sessionApi session or client handle
344
377
  * @param {string} sessionID
345
378
  * @returns {Promise<any[]>}
346
379
  */
347
380
  export async function readTurnTranscript(sessionApi, sessionID) {
381
+ if (typeof sessionApi?.session?.context === 'function') {
382
+ try {
383
+ const response = await sessionApi.session.context({ sessionID });
384
+ const data = unwrapData(response);
385
+ if (Array.isArray(data)) return data;
386
+ if (Array.isArray(data?.data)) return data.data;
387
+ if (Array.isArray(data?.messages)) return data.messages;
388
+ return [];
389
+ } catch {
390
+ /* fall through to message.list */
391
+ }
392
+ }
348
393
  if (typeof sessionApi?.message?.list === 'function') {
349
394
  try {
350
395
  const response = await sessionApi.message.list({ sessionID });
351
396
  const data = unwrapData(response);
352
397
  if (Array.isArray(data)) return data;
353
398
  if (Array.isArray(data?.data)) return data.data;
399
+ if (Array.isArray(data?.messages)) return data.messages;
354
400
  return [];
355
401
  } catch {
356
402
  /* fall through to context */
@@ -369,36 +415,72 @@ export async function readTurnTranscript(sessionApi, sessionID) {
369
415
  }
370
416
 
371
417
  /**
372
- * Collect the final worker answer for a dispatched turn. Version-adaptive:
373
- * 1. A persisted transcript (message.list / session.context) wins when the
374
- * service stores turns.
375
- * 2. Otherwise the generation result ("seed") is the answer — current
376
- * service semantics are synchronous: `generate` blocks until the turn ends
377
- * and returns the full assistant text in `{text}`, persisting nothing.
378
- * 3. With neither available yet, wait briefly for async persistence, then
379
- * give up with whatever exists (bounded, never hangs).
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.
380
424
  * @param {any} sessionApi session or client handle
381
425
  * @param {string} sessionID
382
- * @param {string} seed text returned by `session.generate`
383
426
  * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
384
- * @returns {Promise<string>}
427
+ * @returns {Promise<void>}
385
428
  */
386
- export async function collectWorkerAnswer(
429
+ export async function waitForTurnEnd(
387
430
  sessionApi,
388
431
  sessionID,
389
- seed,
390
- { timeoutMs = 30000, intervalMs = 800 } = {},
432
+ { timeoutMs = 120000, intervalMs = 1500 } = {},
391
433
  ) {
392
- const transcriptText = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
393
- if (transcriptText) return transcriptText;
394
- if (seed) return seed;
395
-
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
+ }
396
445
  const deadline = Date.now() + timeoutMs;
446
+ let previous = '';
447
+ let stableReads = 0;
397
448
  while (Date.now() < deadline) {
398
449
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
399
450
  const text = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
400
- if (text) return text;
451
+ if (text && text === previous) {
452
+ stableReads += 1;
453
+ if (stableReads >= 2) return;
454
+ } else {
455
+ stableReads = 0;
456
+ previous = text;
457
+ }
401
458
  }
459
+ }
460
+
461
+ /**
462
+ * Collect the final worker answer for a dispatched turn. Waits for the turn
463
+ * to end first: the seed from `session.generate` is only the first chunk on
464
+ * current service semantics, so the transcript after settlement wins. The
465
+ * seed is used only as a last resort when it already carries the completion
466
+ * block. Empty returns mean the turn produced nothing (caller reports a
467
+ * bounded failure instead of a false done).
468
+ * @param {any} sessionApi session or client handle
469
+ * @param {string} sessionID
470
+ * @param {string} seed text returned by `session.generate`
471
+ * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
472
+ * @returns {Promise<string>}
473
+ */
474
+ export async function collectWorkerAnswer(
475
+ sessionApi,
476
+ sessionID,
477
+ seed,
478
+ { timeoutMs = 120000, intervalMs = 800 } = {},
479
+ ) {
480
+ await waitForTurnEnd(sessionApi, sessionID, { timeoutMs, intervalMs });
481
+ const finalText = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
482
+ if (finalText) return finalText;
483
+ if (seed && /(?:^|\n)\s*-\s*status\s*[:=]|BLOCKED|NEEDS_CONTEXT/.test(seed)) return seed;
402
484
  return '';
403
485
  }
404
486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "regent-code",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
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