regent-code 3.0.4 → 3.0.5
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/.opencode/INSTALL.md +3 -3
- package/README.md +4 -4
- package/mcp/index.js +29 -7
- package/mcp/shared.js +109 -27
- package/package.json +1 -1
- package/regent-code-3.0.5.tgz +0 -0
- package/regent-code-3.0.4.tgz +0 -0
package/.opencode/INSTALL.md
CHANGED
|
@@ -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.
|
|
9
|
+
npx -y regent-code@3.0.5 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.
|
|
22
|
+
"plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.5"],
|
|
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.
|
|
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.
|
|
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.
|
|
54
|
+
"plugins": ["regent-code@git+https://github.com/nathwn12/regent-code.git#v3.0.5"],
|
|
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.
|
|
72
|
+
known worker sessions stays hard-blocked). The `v3.0.5` 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.
|
|
82
|
+
npx -y regent-code@3.0.5 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.
|
|
88
|
+
- **Plugin**: `plugins` → `regent-code@3.0.5`
|
|
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,
|
|
@@ -285,9 +286,26 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
|
|
|
285
286
|
);
|
|
286
287
|
const seed = extractGenerationText(unwrapData(result));
|
|
287
288
|
|
|
288
|
-
// Collect the final answer
|
|
289
|
-
//
|
|
290
|
-
const output = await collectWorkerAnswer(client, session.id, seed
|
|
289
|
+
// Collect the final answer once the turn has actually ended (generate
|
|
290
|
+
// resolves with the first chunk on current service semantics).
|
|
291
|
+
const output = await collectWorkerAnswer(client, session.id, seed, {
|
|
292
|
+
timeoutMs: Number(process.env.REGENT_TURN_TIMEOUT_MS) || 120000,
|
|
293
|
+
});
|
|
294
|
+
if (!output) {
|
|
295
|
+
try {
|
|
296
|
+
await client.session.interrupt({ sessionID: session.id });
|
|
297
|
+
} catch {
|
|
298
|
+
/* best effort */
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
status: 'blocked',
|
|
302
|
+
output:
|
|
303
|
+
'Subagent error: worker turn ended without producing an answer (stalled or timed out)',
|
|
304
|
+
concerns: [],
|
|
305
|
+
files_changed: [],
|
|
306
|
+
session_id: session.id,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
291
309
|
const parsed = parseSubagentTextResponse(output);
|
|
292
310
|
const { status, concerns, filesChanged } = parsed;
|
|
293
311
|
|
|
@@ -303,7 +321,7 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
|
|
|
303
321
|
|
|
304
322
|
return { status, output, concerns, files_changed: filesChanged, session_id: session.id };
|
|
305
323
|
} catch (err) {
|
|
306
|
-
const message = redactSecrets(
|
|
324
|
+
const message = redactSecrets(safeErrorMessage(err)).slice(0, 500);
|
|
307
325
|
return {
|
|
308
326
|
status: 'blocked',
|
|
309
327
|
output: `Subagent error: ${message}`,
|
|
@@ -360,8 +378,11 @@ function runExplore(query, focus) {
|
|
|
360
378
|
if (focus) {
|
|
361
379
|
const worktreeRoot = path.resolve(worktree);
|
|
362
380
|
const focusPath = path.resolve(worktree, focus);
|
|
381
|
+
const absoluteFocus = path.isAbsolute(focus);
|
|
382
|
+
// Absolute focus paths may point outside the project (e.g. another repo);
|
|
383
|
+
// the sensitive-path guards still apply to every segment of the path.
|
|
363
384
|
const lexicalInside =
|
|
364
|
-
focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot;
|
|
385
|
+
absoluteFocus || focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot;
|
|
365
386
|
if (!lexicalInside) {
|
|
366
387
|
result += `\n## Focus: ${focus}\n(path outside project directory)\n`;
|
|
367
388
|
} else if (isSensitiveFocusPath(focusPath, worktreeRoot)) {
|
|
@@ -376,8 +397,9 @@ function runExplore(query, focus) {
|
|
|
376
397
|
/* fall back to lexical paths */
|
|
377
398
|
}
|
|
378
399
|
const inside =
|
|
379
|
-
|
|
380
|
-
(
|
|
400
|
+
absoluteFocus ||
|
|
401
|
+
((realFocus.startsWith(realRoot + path.sep) || realFocus === realRoot) &&
|
|
402
|
+
(focusPath.startsWith(worktreeRoot + path.sep) || focusPath === worktreeRoot));
|
|
381
403
|
if (!inside) {
|
|
382
404
|
result += `\n## Focus: ${focus}\n(path outside project directory)\n`;
|
|
383
405
|
} else if (isSensitiveFocusPath(realFocus, realRoot)) {
|
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
|
-
|
|
243
|
-
|
|
261
|
+
const trimmed = String(text || '').trim();
|
|
262
|
+
if (!trimmed) {
|
|
263
|
+
return { status: 'blocked', concerns: ['worker returned no output'], filesChanged: [] };
|
|
264
|
+
}
|
|
244
265
|
|
|
245
|
-
|
|
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 (
|
|
273
|
+
} else if (trimmed.includes('NEEDS_CONTEXT')) {
|
|
248
274
|
status = 'needs_context';
|
|
249
|
-
} else if (
|
|
275
|
+
} else if (trimmed.includes('CONCERN:')) {
|
|
250
276
|
status = 'done_with_concerns';
|
|
251
|
-
|
|
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 =
|
|
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
|
|
342
|
-
*
|
|
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
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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<
|
|
427
|
+
* @returns {Promise<void>}
|
|
385
428
|
*/
|
|
386
|
-
export async function
|
|
429
|
+
export async function waitForTurnEnd(
|
|
387
430
|
sessionApi,
|
|
388
431
|
sessionID,
|
|
389
|
-
|
|
390
|
-
{ timeoutMs = 30000, intervalMs = 800 } = {},
|
|
432
|
+
{ timeoutMs = 120000, intervalMs = 1500 } = {},
|
|
391
433
|
) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
Binary file
|
package/regent-code-3.0.4.tgz
DELETED
|
Binary file
|