regent-code 3.0.1 → 3.0.3
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/.opencode/package.json +1 -1
- package/.opencode/plugins/regent.js +168 -6
- package/README.md +32 -8
- package/mcp/index.js +26 -6
- package/mcp/install.js +25 -6
- package/mcp/shared.js +130 -0
- package/mcp/version.js +9 -0
- package/package.json +3 -3
- package/regent-code-3.0.3.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.3 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.3"],
|
|
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.3` 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/.opencode/package.json
CHANGED
|
@@ -530,6 +530,135 @@ function unwrapData(result) {
|
|
|
530
530
|
return result?.data ?? result;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
+
// ── Worker-turn completion (version-adaptive) ─────────────────
|
|
534
|
+
// The service resolves `session.generate` with the FIRST generated text
|
|
535
|
+
// chunk while the turn continues asynchronously (behavior introduced after
|
|
536
|
+
// beta-18314). The helpers below make the turn protocol version-agnostic:
|
|
537
|
+
// extract whatever shape the generation result has, wait until the session
|
|
538
|
+
// counters prove the turn finished, then read the final assistant text from
|
|
539
|
+
// the transcript. All of them degrade gracefully on legacy session handles.
|
|
540
|
+
|
|
541
|
+
/** @param {unknown} text */
|
|
542
|
+
function isTextPart(text) {
|
|
543
|
+
if (typeof text !== 'object' || text === null) return false;
|
|
544
|
+
/** @type {Record<string, any>} */
|
|
545
|
+
const obj = text;
|
|
546
|
+
return obj.type === 'text' && typeof obj.text === 'string';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Extract worker text from a generation result regardless of its shape.
|
|
551
|
+
* Supports `{ text }`, `{ message: { text } }`, `{ parts: [...] }`,
|
|
552
|
+
* `{ content: [...] | string }`, and raw strings.
|
|
553
|
+
* @param {unknown} result
|
|
554
|
+
* @returns {string}
|
|
555
|
+
*/
|
|
556
|
+
function extractGenerationText(result) {
|
|
557
|
+
if (typeof result === 'string' && result.trim()) return result;
|
|
558
|
+
if (!result || typeof result !== 'object') return '';
|
|
559
|
+
/** @type {Record<string, any>} */
|
|
560
|
+
const obj = result;
|
|
561
|
+
if (typeof obj.text === 'string' && obj.text.trim()) return obj.text;
|
|
562
|
+
const parts = obj.parts ?? obj.content ?? obj.message?.parts ?? obj.message?.content;
|
|
563
|
+
if (typeof parts === 'string') return parts.trim();
|
|
564
|
+
if (Array.isArray(parts)) {
|
|
565
|
+
const chunks = parts.filter(isTextPart).map((part) => part.text);
|
|
566
|
+
if (chunks.length > 0) return chunks.join('\n').trim();
|
|
567
|
+
}
|
|
568
|
+
return '';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Join the assistant text of a session transcript (SessionMessageInfo[]).
|
|
573
|
+
* Only `type: "assistant"` messages contribute; reasoning/tool parts are
|
|
574
|
+
* skipped. Handles both chronological and reverse orderings.
|
|
575
|
+
* @param {any[]} messages
|
|
576
|
+
* @returns {string}
|
|
577
|
+
*/
|
|
578
|
+
function joinAssistantText(messages) {
|
|
579
|
+
if (!Array.isArray(messages)) return '';
|
|
580
|
+
const chunks = [];
|
|
581
|
+
for (const message of messages) {
|
|
582
|
+
if (!message || message.type !== 'assistant') continue;
|
|
583
|
+
const content = message.content;
|
|
584
|
+
if (typeof content === 'string') {
|
|
585
|
+
chunks.push(content);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (Array.isArray(content)) {
|
|
589
|
+
for (const part of content) {
|
|
590
|
+
if (isTextPart(part)) chunks.push(part.text);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return chunks.join('\n').trim();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Read the latest session transcript through whatever the session handle
|
|
599
|
+
* exposes: `message.list` first, then `session.context`, then nothing.
|
|
600
|
+
* @param {any} sessionApi
|
|
601
|
+
* @param {string} sessionID
|
|
602
|
+
* @returns {Promise<any[]>}
|
|
603
|
+
*/
|
|
604
|
+
async function readTurnTranscript(sessionApi, sessionID) {
|
|
605
|
+
if (typeof sessionApi?.message?.list === 'function') {
|
|
606
|
+
try {
|
|
607
|
+
const response = await sessionApi.message.list({ sessionID });
|
|
608
|
+
const data = unwrapData(response);
|
|
609
|
+
if (Array.isArray(data)) return data;
|
|
610
|
+
if (Array.isArray(data?.data)) return data.data;
|
|
611
|
+
return [];
|
|
612
|
+
} catch {
|
|
613
|
+
/* fall through to context */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (typeof sessionApi?.context === 'function') {
|
|
617
|
+
try {
|
|
618
|
+
const response = await sessionApi.context({ sessionID });
|
|
619
|
+
const data = unwrapData(response);
|
|
620
|
+
return Array.isArray(data) ? data : [];
|
|
621
|
+
} catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Collect the final worker answer for a dispatched turn. Version-adaptive:
|
|
630
|
+
* 1. A persisted transcript (message.list / session.context) wins when the
|
|
631
|
+
* service stores turns.
|
|
632
|
+
* 2. Otherwise the generation result ("seed") is the answer — current
|
|
633
|
+
* service semantics are synchronous: `generate` blocks until the turn ends
|
|
634
|
+
* and returns the full assistant text in `{text}`, persisting nothing.
|
|
635
|
+
* 3. With neither available yet, wait briefly for async persistence, then
|
|
636
|
+
* give up with whatever exists (bounded, never hangs).
|
|
637
|
+
* @param {any} sessionApi
|
|
638
|
+
* @param {string} sessionID
|
|
639
|
+
* @param {string} seed text returned by `session.generate`
|
|
640
|
+
* @param {{ timeoutMs?: number, intervalMs?: number }} [options]
|
|
641
|
+
* @returns {Promise<string>}
|
|
642
|
+
*/
|
|
643
|
+
async function collectWorkerAnswer(
|
|
644
|
+
sessionApi,
|
|
645
|
+
sessionID,
|
|
646
|
+
seed,
|
|
647
|
+
{ timeoutMs = 30000, intervalMs = 800 } = {},
|
|
648
|
+
) {
|
|
649
|
+
const transcriptText = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
|
|
650
|
+
if (transcriptText) return transcriptText;
|
|
651
|
+
if (seed) return seed;
|
|
652
|
+
|
|
653
|
+
const deadline = Date.now() + timeoutMs;
|
|
654
|
+
while (Date.now() < deadline) {
|
|
655
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
656
|
+
const text = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
|
|
657
|
+
if (text) return text;
|
|
658
|
+
}
|
|
659
|
+
return '';
|
|
660
|
+
}
|
|
661
|
+
|
|
533
662
|
function normalizeAgents(result) {
|
|
534
663
|
const data = unwrapData(result);
|
|
535
664
|
if (Array.isArray(data)) return data;
|
|
@@ -564,6 +693,10 @@ function isUnavailableAgentError(err) {
|
|
|
564
693
|
}
|
|
565
694
|
|
|
566
695
|
async function createWorkerResolver(agentApi, options = {}) {
|
|
696
|
+
const locationInput =
|
|
697
|
+
options && typeof options.location === 'string'
|
|
698
|
+
? { location: { directory: options.location } }
|
|
699
|
+
: {};
|
|
567
700
|
let catalog = null;
|
|
568
701
|
if (typeof agentApi?.list === 'function') {
|
|
569
702
|
try {
|
|
@@ -571,6 +704,15 @@ async function createWorkerResolver(agentApi, options = {}) {
|
|
|
571
704
|
} catch {
|
|
572
705
|
catalog = null;
|
|
573
706
|
}
|
|
707
|
+
// Some service versions require an explicit location scope; retry when
|
|
708
|
+
// the un-scoped call came back empty instead of giving up on the catalog.
|
|
709
|
+
if ((!Array.isArray(catalog) || catalog.length === 0) && locationInput.location) {
|
|
710
|
+
try {
|
|
711
|
+
catalog = normalizeAgents(await agentApi.list(locationInput));
|
|
712
|
+
} catch {
|
|
713
|
+
catalog = null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
574
716
|
}
|
|
575
717
|
|
|
576
718
|
const findAgent = async (id) => {
|
|
@@ -578,7 +720,7 @@ async function createWorkerResolver(agentApi, options = {}) {
|
|
|
578
720
|
if (fromCatalog) return fromCatalog;
|
|
579
721
|
if (catalog !== null || typeof agentApi?.get !== 'function') return undefined;
|
|
580
722
|
try {
|
|
581
|
-
return unwrapData(await agentApi.get({ agentID: id }));
|
|
723
|
+
return unwrapData(await agentApi.get({ agentID: id, ...locationInput }));
|
|
582
724
|
} catch {
|
|
583
725
|
return undefined;
|
|
584
726
|
}
|
|
@@ -642,8 +784,22 @@ async function createWorkerResolver(agentApi, options = {}) {
|
|
|
642
784
|
? caller.id
|
|
643
785
|
: '';
|
|
644
786
|
const callerAgent = callerId ? await findAgent(callerId) : undefined;
|
|
645
|
-
|
|
646
|
-
|
|
787
|
+
|
|
788
|
+
// Resolvable caller: strict primary-capable gate.
|
|
789
|
+
if (callerAgent) {
|
|
790
|
+
if (!isPrimaryCapableAgent(callerAgent)) {
|
|
791
|
+
return 'caller is not a visible primary-capable agent; subagent dispatch is blocked';
|
|
792
|
+
}
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Unresolvable caller — the agent API shape or visibility policy changed
|
|
797
|
+
// at runtime. Degrade OPEN instead of breaking dispatch for every primary
|
|
798
|
+
// session. The only hard block that survives is recursion from a worker
|
|
799
|
+
// session this plugin created.
|
|
800
|
+
const callerSessionId = typeof toolContext?.sessionID === 'string' ? toolContext.sessionID : '';
|
|
801
|
+
if (callerSessionId && pluginWorkerSessionIds.has(callerSessionId)) {
|
|
802
|
+
return 'caller is a Regent worker session; nested subagent dispatch is blocked';
|
|
647
803
|
}
|
|
648
804
|
return null;
|
|
649
805
|
};
|
|
@@ -772,8 +928,11 @@ async function dispatchSubagent(
|
|
|
772
928
|
].join('\n');
|
|
773
929
|
|
|
774
930
|
const result = await withRetry(() => sessionApi.generate({ sessionID: session.id, prompt }));
|
|
775
|
-
const
|
|
776
|
-
|
|
931
|
+
const seed = extractGenerationText(unwrapData(result));
|
|
932
|
+
|
|
933
|
+
// Collect the final answer: persisted transcript wins, otherwise the
|
|
934
|
+
// synchronous generation result is the answer.
|
|
935
|
+
const output = await collectWorkerAnswer(sessionApi, session.id, seed);
|
|
777
936
|
const parsed = parseSubagentTextResponse(output);
|
|
778
937
|
const { status, concerns, filesChanged } = parsed;
|
|
779
938
|
|
|
@@ -971,7 +1130,10 @@ export default Plugin.define({
|
|
|
971
1130
|
async setup(ctx) {
|
|
972
1131
|
const registrations = [];
|
|
973
1132
|
const options = ctx.options && typeof ctx.options === 'object' ? ctx.options : {};
|
|
974
|
-
const resolveWorker = await createWorkerResolver(ctx.agent,
|
|
1133
|
+
const resolveWorker = await createWorkerResolver(ctx.agent, {
|
|
1134
|
+
...options,
|
|
1135
|
+
location: options.location ?? ctx.location?.directory ?? process.cwd(),
|
|
1136
|
+
});
|
|
975
1137
|
|
|
976
1138
|
if (typeof options.primaryAgent === 'string' && options.primaryAgent.trim()) {
|
|
977
1139
|
if (typeof ctx.agent?.transform === 'function') {
|
package/README.md
CHANGED
|
@@ -51,11 +51,26 @@ 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.3"],
|
|
55
55
|
}
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
### Version compatibility (dynamic, not pinned)
|
|
59
|
+
|
|
60
|
+
Since v3.0.3 regent-code resolves `@opencode-ai/client` and
|
|
61
|
+
`@opencode-ai/plugin` through the OpenCode **`beta` dist-tag** (`"beta"` in
|
|
62
|
+
`package.json`), which always points at the current beta build — the same
|
|
63
|
+
channel the CLI ships on. Every install resolves automatically to the newest
|
|
64
|
+
beta; no version chasing, no manual pin updates. (Avoid `npm install
|
|
65
|
+
pkg@<version>` in this repo: npm silently rewrites dependency ranges in the
|
|
66
|
+
manifest when given explicit versions — use plain `npm install` or `npm
|
|
67
|
+
update`.) The dispatch code is *runtime-adaptive* on top: generation results
|
|
68
|
+
are read from the persisted transcript when the service stores turns, otherwise
|
|
69
|
+
from the synchronous `session.generate` text; agent catalogs are queried with
|
|
70
|
+
and without an explicit location scope; and the caller-authorization guardrail
|
|
71
|
+
degrades OPEN when the runtime agent API is unresolvable (only recursion from
|
|
72
|
+
known worker sessions stays hard-blocked). The `v3.0.3` git tag must be pushed
|
|
73
|
+
to GitHub before this pinned spec resolves.
|
|
59
74
|
|
|
60
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.
|
|
61
76
|
|
|
@@ -64,16 +79,18 @@ This release targets the OpenCode v2 beta plugin API (`@opencode-ai/plugin@0.0.0
|
|
|
64
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:
|
|
65
80
|
|
|
66
81
|
```bash
|
|
67
|
-
npx -y regent-code@3.0.
|
|
82
|
+
npx -y regent-code@3.0.3 install
|
|
68
83
|
```
|
|
69
84
|
|
|
70
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:
|
|
71
86
|
|
|
72
|
-
- **MCP server**: `mcp.servers.regent` → runs `
|
|
73
|
-
- **Plugin**: `plugins` → `regent-code@3.0.
|
|
87
|
+
- **MCP server**: `mcp.servers.regent` → runs `node <absolute path to this package's mcp/cli.js>` directly. No per-connection npx bootstrap: `npx -y regent-code` re-resolves against the npm registry on every OpenCode session start and can re-provision the npx cache, which makes MCP connects flaky (handshake timeouts / connection-closed). Launching the installed file with `node` is deterministic and offline.
|
|
88
|
+
- **Plugin**: `plugins` → `regent-code@3.0.3`
|
|
74
89
|
|
|
75
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.
|
|
76
91
|
|
|
92
|
+
> When the installer is invoked from npm's ephemeral npx cache, it prints a warning: the absolute path it wrote can be pruned by npm. Run `npm install -g regent-code@3.0.3` once and re-run the installer so the entry points at the persistent global install.
|
|
93
|
+
|
|
77
94
|
> On the machine that develops regent-code itself, respect the single-source rule above: do not add a second pin when the repo is open as a project.
|
|
78
95
|
|
|
79
96
|
## MCP Server
|
|
@@ -99,7 +116,14 @@ Configure it in OpenCode by adding a local MCP server:
|
|
|
99
116
|
|
|
100
117
|
`<REPO>` is the path to where the repo is cloned; `cwd: "."` makes the server operate on the workspace directory, which is what the `explore` tool and subagent dispatch use as the working directory.
|
|
101
118
|
|
|
102
|
-
**Other machines / people — no local clone needed.** The package is published to npm,
|
|
119
|
+
**Other machines / people — no local clone needed.** The package is published to npm. Recommended: install globally once, then let the installer write the entry (see "One-command install" above):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install -g regent-code@3.0.3
|
|
123
|
+
npx -y regent-code@3.0.3 install --global
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Or add the server to `opencode.jsonc` manually with a direct node launch:
|
|
103
127
|
|
|
104
128
|
```jsonc
|
|
105
129
|
{
|
|
@@ -108,14 +132,14 @@ Configure it in OpenCode by adding a local MCP server:
|
|
|
108
132
|
"servers": {
|
|
109
133
|
"regent": {
|
|
110
134
|
"type": "local",
|
|
111
|
-
"command": ["
|
|
135
|
+
"command": ["node", "<NPM_GLOBAL>/regent-code/mcp/cli.js"]
|
|
112
136
|
}
|
|
113
137
|
}
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
140
|
```
|
|
117
141
|
|
|
118
|
-
`
|
|
142
|
+
`<NPM_GLOBAL>` is the npm global install directory (`npm root -g`). Launching the installed file with `node` means every OpenCode session start spawns the server directly — no per-connection npx registry resolution, so connection is deterministic and works offline. Dispatch tools still require a local OpenCode service to be running (see below), so the server behaves identically to a local install.
|
|
119
143
|
|
|
120
144
|
Tool names surface in OpenCode as `<server>_<tool>`, so `regent_delegate`, `regent_delegate_many`, `regent_research`, `regent_explore`, `regent_changed-files`, `regent_verify`. Prompts surface as commands named `<server>:<prompt>`; the prompts are namespaced `command.<name>` (e.g. `/regent:command.orchestrate`, `/regent:command.tdd`) and `skill.<id>` (e.g. `/regent:skill.using-regent`).
|
|
121
145
|
|
package/mcp/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
isSensitiveFocusPath,
|
|
30
30
|
parseSubagentTextResponse,
|
|
31
31
|
unwrapData,
|
|
32
|
+
extractGenerationText,
|
|
33
|
+
collectWorkerAnswer,
|
|
32
34
|
sessionFileChanges,
|
|
33
35
|
workerSessionIds,
|
|
34
36
|
dispatchRateLimit,
|
|
@@ -40,8 +42,7 @@ import {
|
|
|
40
42
|
} from './shared.js';
|
|
41
43
|
|
|
42
44
|
import { readPackagePrompts, renderPrompt } from './prompts.js';
|
|
43
|
-
|
|
44
|
-
const version = '3.0.1';
|
|
45
|
+
import { version } from './version.js';
|
|
45
46
|
|
|
46
47
|
// ── OpenCode client (lazy singleton) ─────────────────────────
|
|
47
48
|
let clientPromise = null;
|
|
@@ -104,23 +105,36 @@ function isUnavailableAgentError(err) {
|
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
107
|
* @param {ReturnType<typeof OpenCode.make>} client
|
|
107
|
-
* @param {{ workerAgent?: string }} [options]
|
|
108
|
+
* @param {{ workerAgent?: string, location?: string }} [options]
|
|
108
109
|
* @returns {Promise<(requestedAgent?: string) => Promise<{ ok: true, agents: string[], automatic: boolean } | { ok: false, error: string }>>}
|
|
109
110
|
*/
|
|
110
111
|
async function createWorkerResolver(client, options = {}) {
|
|
112
|
+
const locationInput =
|
|
113
|
+
options && typeof options.location === 'string'
|
|
114
|
+
? { location: { directory: options.location } }
|
|
115
|
+
: {};
|
|
111
116
|
let catalog = null;
|
|
112
117
|
try {
|
|
113
118
|
catalog = normalizeAgents(await client.agent.list());
|
|
114
119
|
} catch {
|
|
115
120
|
catalog = null;
|
|
116
121
|
}
|
|
122
|
+
// Some service versions require an explicit location scope; retry when the
|
|
123
|
+
// un-scoped call came back empty instead of giving up on the catalog.
|
|
124
|
+
if ((!Array.isArray(catalog) || catalog.length === 0) && locationInput.location) {
|
|
125
|
+
try {
|
|
126
|
+
catalog = normalizeAgents(await client.agent.list(locationInput));
|
|
127
|
+
} catch {
|
|
128
|
+
catalog = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
117
131
|
|
|
118
132
|
const findAgent = async (id) => {
|
|
119
133
|
const fromCatalog = catalog?.find((agent) => agent.id === id);
|
|
120
134
|
if (fromCatalog) return fromCatalog;
|
|
121
135
|
if (catalog !== null) return undefined;
|
|
122
136
|
try {
|
|
123
|
-
return unwrapData(await client.agent.get({ agentID: id }));
|
|
137
|
+
return unwrapData(await client.agent.get({ agentID: id, ...locationInput }));
|
|
124
138
|
} catch {
|
|
125
139
|
return undefined;
|
|
126
140
|
}
|
|
@@ -269,8 +283,11 @@ async function dispatchSubagent(client, resolveWorker, args, taskId = '') {
|
|
|
269
283
|
const result = await withRetry(() =>
|
|
270
284
|
client.session.generate({ sessionID: session.id, prompt }),
|
|
271
285
|
);
|
|
272
|
-
const
|
|
273
|
-
|
|
286
|
+
const seed = extractGenerationText(unwrapData(result));
|
|
287
|
+
|
|
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);
|
|
274
291
|
const parsed = parseSubagentTextResponse(output);
|
|
275
292
|
const { status, concerns, filesChanged } = parsed;
|
|
276
293
|
|
|
@@ -595,6 +612,7 @@ export function createRegentServer() {
|
|
|
595
612
|
const client = await getClient();
|
|
596
613
|
const resolveWorker = await createWorkerResolver(client, {
|
|
597
614
|
workerAgent: process.env.REGENT_WORKER_AGENT,
|
|
615
|
+
location: process.cwd(),
|
|
598
616
|
});
|
|
599
617
|
const result = await dispatchSubagent(client, resolveWorker, /** @type {any} */ (args));
|
|
600
618
|
return toContent(result);
|
|
@@ -619,6 +637,7 @@ export function createRegentServer() {
|
|
|
619
637
|
const client = await getClient();
|
|
620
638
|
const resolveWorker = await createWorkerResolver(client, {
|
|
621
639
|
workerAgent: process.env.REGENT_WORKER_AGENT,
|
|
640
|
+
location: process.cwd(),
|
|
622
641
|
});
|
|
623
642
|
const queue = [...args.tasks];
|
|
624
643
|
const results = [];
|
|
@@ -663,6 +682,7 @@ export function createRegentServer() {
|
|
|
663
682
|
const client = await getClient();
|
|
664
683
|
const resolveWorker = await createWorkerResolver(client, {
|
|
665
684
|
workerAgent: process.env.REGENT_WORKER_AGENT,
|
|
685
|
+
location: process.cwd(),
|
|
666
686
|
});
|
|
667
687
|
const results = await Promise.all(
|
|
668
688
|
args.questions.map(async (q) => {
|
package/mcp/install.js
CHANGED
|
@@ -8,20 +8,30 @@
|
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
9
9
|
import { join, dirname, resolve } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
-
import {
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { parseTree, findNodeAtLocation, getNodeValue, modify, applyEdits } from 'jsonc-parser';
|
|
13
|
-
|
|
14
|
-
const require = createRequire(import.meta.url);
|
|
15
|
-
const { version } = require('../package.json');
|
|
13
|
+
import { version } from './version.js';
|
|
16
14
|
|
|
17
15
|
export const PLUGIN_SPEC = `regent-code@${version}`;
|
|
18
16
|
export const SERVER_NAME = 'regent';
|
|
17
|
+
|
|
18
|
+
// Launch the MCP server straight from the installed package. A per-connection
|
|
19
|
+
// `npx -y regent-code` bootstraps npm resolution against the registry and can
|
|
20
|
+
// re-provision the npx cache on every OpenCode session start, which makes MCP
|
|
21
|
+
// connects flaky (handshake timeouts / connection-closed). `node <path>` is
|
|
22
|
+
// deterministic and offline.
|
|
23
|
+
const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
24
|
+
|
|
19
25
|
export const SERVER_CONFIG = Object.freeze({
|
|
20
26
|
type: 'local',
|
|
21
|
-
command: ['
|
|
27
|
+
command: ['node', CLI_PATH],
|
|
22
28
|
cwd: '.',
|
|
23
29
|
});
|
|
24
30
|
|
|
31
|
+
// npm's ephemeral npx cache (`_npx/<hash>/...`) can be pruned by npm; warn the
|
|
32
|
+
// user to install globally so the config's absolute path keeps resolving.
|
|
33
|
+
const EPHEMERAL_NPX_CACHE = '_npx';
|
|
34
|
+
|
|
25
35
|
const CONFIG_NAMES = ['opencode.jsonc', 'opencode.json'];
|
|
26
36
|
|
|
27
37
|
export class InstallError extends Error {}
|
|
@@ -198,7 +208,15 @@ export function install(options = {}) {
|
|
|
198
208
|
const existingText = existsSync(target.path) ? readFileSync(target.path, 'utf8') : null;
|
|
199
209
|
const base = existingText ?? `{\n}\n`;
|
|
200
210
|
const { text, report } = patchText(base);
|
|
201
|
-
|
|
211
|
+
const warnings = [...report.warnings];
|
|
212
|
+
|
|
213
|
+
if (CLI_PATH.includes(EPHEMERAL_NPX_CACHE)) {
|
|
214
|
+
warnings.push(
|
|
215
|
+
`MCP entry points into npm's ephemeral npx cache (${CLI_PATH}); npm can prune it. ` +
|
|
216
|
+
`Run "npm install -g ${PLUGIN_SPEC}" once, then re-run "npx -y ${PLUGIN_SPEC} install" ` +
|
|
217
|
+
`so the config points at the persistent global install.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
202
220
|
if (text !== existingText) {
|
|
203
221
|
mkdirSync(dirname(target.path), { recursive: true });
|
|
204
222
|
writeFileSync(target.path, text);
|
|
@@ -206,6 +224,7 @@ export function install(options = {}) {
|
|
|
206
224
|
|
|
207
225
|
return {
|
|
208
226
|
...report,
|
|
227
|
+
...(warnings.length ? { warnings } : {}),
|
|
209
228
|
path: target.path,
|
|
210
229
|
scope: target.scope,
|
|
211
230
|
created: existingText === null,
|
package/mcp/shared.js
CHANGED
|
@@ -272,6 +272,136 @@ export function unwrapData(result) {
|
|
|
272
272
|
return result?.data ?? result;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ── Worker-turn completion (version-adaptive) ─────────────────
|
|
276
|
+
// The service resolves `session.generate` with the FIRST generated text
|
|
277
|
+
// chunk while the turn continues asynchronously (behavior introduced after
|
|
278
|
+
// beta-18314). These helpers make the turn completion protocol version-
|
|
279
|
+
// agnostic: extract whatever shape the generation result has, wait until the
|
|
280
|
+
// session's token/time counters prove the turn finished, then read the final
|
|
281
|
+
// assistant text from the transcript.
|
|
282
|
+
|
|
283
|
+
/** @param {unknown} text */
|
|
284
|
+
function isTextPart(text) {
|
|
285
|
+
if (typeof text !== 'object' || text === null) return false;
|
|
286
|
+
/** @type {Record<string, any>} */
|
|
287
|
+
const obj = text;
|
|
288
|
+
return obj.type === 'text' && typeof obj.text === 'string';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Extract worker text from a generation result regardless of its shape.
|
|
293
|
+
* Supports: `{ text }`, `{ message: { text } }`, `{ parts: [{type:"text",text}] }`,
|
|
294
|
+
* `{ content: [{type:"text",text}] | string }`, and raw string results.
|
|
295
|
+
* @param {unknown} result
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
export function extractGenerationText(result) {
|
|
299
|
+
if (typeof result === 'string' && result.trim()) return result;
|
|
300
|
+
if (!result || typeof result !== 'object') return '';
|
|
301
|
+
/** @type {Record<string, any>} */
|
|
302
|
+
const obj = result;
|
|
303
|
+
if (typeof obj.text === 'string' && obj.text.trim()) return obj.text;
|
|
304
|
+
const parts = obj.parts ?? obj.content ?? obj.message?.parts ?? obj.message?.content;
|
|
305
|
+
if (typeof parts === 'string') return parts.trim();
|
|
306
|
+
if (Array.isArray(parts)) {
|
|
307
|
+
const chunks = parts.filter(isTextPart).map((part) => part.text);
|
|
308
|
+
if (chunks.length > 0) return chunks.join('\n').trim();
|
|
309
|
+
}
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Join the assistant text of a session transcript (SessionMessageInfo[]).
|
|
315
|
+
* Only `type: "assistant"` messages contribute; reasoning/tool parts are
|
|
316
|
+
* skipped. Handles both chronological and reverse (desc) orderings.
|
|
317
|
+
* @param {any[]} messages
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
export function joinAssistantText(messages) {
|
|
321
|
+
if (!Array.isArray(messages)) return '';
|
|
322
|
+
const chunks = [];
|
|
323
|
+
for (const message of messages) {
|
|
324
|
+
if (!message || message.type !== 'assistant') continue;
|
|
325
|
+
const content = message.content;
|
|
326
|
+
if (typeof content === 'string') {
|
|
327
|
+
chunks.push(content);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (Array.isArray(content)) {
|
|
331
|
+
for (const part of content) {
|
|
332
|
+
if (isTextPart(part)) chunks.push(part.text);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return chunks.join('\n').trim();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 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.
|
|
343
|
+
* @param {any} sessionApi session or client handle
|
|
344
|
+
* @param {string} sessionID
|
|
345
|
+
* @returns {Promise<any[]>}
|
|
346
|
+
*/
|
|
347
|
+
export async function readTurnTranscript(sessionApi, sessionID) {
|
|
348
|
+
if (typeof sessionApi?.message?.list === 'function') {
|
|
349
|
+
try {
|
|
350
|
+
const response = await sessionApi.message.list({ sessionID });
|
|
351
|
+
const data = unwrapData(response);
|
|
352
|
+
if (Array.isArray(data)) return data;
|
|
353
|
+
if (Array.isArray(data?.data)) return data.data;
|
|
354
|
+
return [];
|
|
355
|
+
} catch {
|
|
356
|
+
/* fall through to context */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (typeof sessionApi?.context === 'function') {
|
|
360
|
+
try {
|
|
361
|
+
const response = await sessionApi.context({ sessionID });
|
|
362
|
+
const data = unwrapData(response);
|
|
363
|
+
return Array.isArray(data) ? data : [];
|
|
364
|
+
} catch {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
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).
|
|
380
|
+
* @param {any} sessionApi session or client handle
|
|
381
|
+
* @param {string} sessionID
|
|
382
|
+
* @param {string} seed text returned by `session.generate`
|
|
383
|
+
* @param {{ timeoutMs?: number, intervalMs?: number }} [options]
|
|
384
|
+
* @returns {Promise<string>}
|
|
385
|
+
*/
|
|
386
|
+
export async function collectWorkerAnswer(
|
|
387
|
+
sessionApi,
|
|
388
|
+
sessionID,
|
|
389
|
+
seed,
|
|
390
|
+
{ timeoutMs = 30000, intervalMs = 800 } = {},
|
|
391
|
+
) {
|
|
392
|
+
const transcriptText = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
|
|
393
|
+
if (transcriptText) return transcriptText;
|
|
394
|
+
if (seed) return seed;
|
|
395
|
+
|
|
396
|
+
const deadline = Date.now() + timeoutMs;
|
|
397
|
+
while (Date.now() < deadline) {
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
399
|
+
const text = joinAssistantText(await readTurnTranscript(sessionApi, sessionID));
|
|
400
|
+
if (text) return text;
|
|
401
|
+
}
|
|
402
|
+
return '';
|
|
403
|
+
}
|
|
404
|
+
|
|
275
405
|
// ── State (single MCP process scope; no session lineage) ──
|
|
276
406
|
/** @type {Map<string, { taskId?: string, files: string[], timestamp: number, verified: boolean }>} */
|
|
277
407
|
export const sessionFileChanges = new Map();
|
package/mcp/version.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Single dynamic source for the package version: read from the installed
|
|
2
|
+
// package.json at runtime, never hardcoded. Both the installer (plugin/MCP
|
|
3
|
+
// spec pins) and the MCP server (serverInfo) import from here, so a version
|
|
4
|
+
// bump in package.json propagates everywhere without touching code.
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
export const version = require('../package.json').version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "regent-code",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
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",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"author": "nathwn12",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
18
|
-
"@opencode-ai/client": "
|
|
19
|
-
"@opencode-ai/plugin": "
|
|
18
|
+
"@opencode-ai/client": "beta",
|
|
19
|
+
"@opencode-ai/plugin": "beta",
|
|
20
20
|
"jsonc-parser": "3.3.1"
|
|
21
21
|
},
|
|
22
22
|
"repository": {
|
|
Binary file
|