@dudousxd/nestjs-agent-opencode 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Davide Carvalho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # @dudousxd/nestjs-agent-opencode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run `@dudousxd/nestjs-agent` turns on [OpenCode 2](https://opencode.ai). The library keeps the
4
+ routes, threads, stream protocol, approvals, questions and queue (so `@dudousxd/nestjs-agent-react`
5
+ works unchanged); OpenCode runs the model, the tools, skills and the context.
6
+
7
+ ```ts
8
+ import { AgentModule } from '@dudousxd/nestjs-agent';
9
+ import { openCode, type OpenCodeHost } from '@dudousxd/nestjs-agent-opencode';
10
+
11
+ @Injectable()
12
+ class MyOpenCodeHost implements OpenCodeHost {
13
+ constructor(private readonly sandboxes: SandboxService) {}
14
+
15
+ /** Which OpenCode server runs this actor's turns. */
16
+ async server(actor: Actor) {
17
+ const rt = await this.sandboxes.runtimeFor(actor.tenantId);
18
+ return { client: rt.client, key: actor.tenantId, bootId: rt.bootId };
19
+ }
20
+
21
+ /** How a thread's session is created (only when it has none on this server). */
22
+ async session({ input }: OpenCodeTurnContext) {
23
+ return {
24
+ location: { directory: `/work/${input.actor.id}` },
25
+ permissions: [
26
+ { action: '*', resource: '*', effect: 'deny' },
27
+ { action: 'execute', resource: '*', effect: 'allow' },
28
+ { action: 'company.*send*', resource: '*', effect: 'ask' }, // → approval card
29
+ { action: 'question', resource: '*', effect: 'allow' }, // → elicitation form
30
+ ],
31
+ };
32
+ }
33
+
34
+ /** Extra instructions entries, refreshed every turn. */
35
+ async instructions({ input }: OpenCodeTurnContext) {
36
+ return { 'app.profile': await this.profiles.describe(input.actor) };
37
+ }
38
+ }
39
+
40
+ @Module({
41
+ imports: [
42
+ AgentModule.forRoot({
43
+ engine: openCode({ host: MyOpenCodeHost }),
44
+ store, // any AgentStore
45
+ actorResolver,
46
+ }),
47
+ ],
48
+ providers: [MyAgent], // @Agent({ systemPrompt }) / @SystemPrompt() → instructions `aviary.system`
49
+ })
50
+ export class AppModule {}
51
+ ```
52
+
53
+ | OpenCode | The library's protocol and store |
54
+ | --- | --- |
55
+ | `session.text.delta` / `session.reasoning.delta` | `text` / `reasoning` |
56
+ | a step (`session.step.ended`) | `step-start` … `step-finish` with usage; usage recorded per step |
57
+ | tools (`session.tool.*`) | `tool-input-*` / `tool-output*`, code-mode inner calls nested by `parentId` |
58
+ | `permission.asked` | an `action` call + `approval-requested`, recorded `pending_approval`; `approve` / `reject` → `permission.reply` |
59
+ | `form.created` | `elicitation`; `answer` / `skip` → `session.form.reply` / `cancel` |
60
+ | `session.renamed` | `title` |
61
+ | `session.execution.*` | the run settles: queue handoff, `cancelled`, or a typed stream failure |
62
+ | `cancel` | `session.interrupt` |
63
+
64
+ Sessions: one per thread, kept by an `OpenCodeSessionStore` (in memory by default; persist it for
65
+ several replicas) and recreated when the server's `bootId` changes, told the conversation so far.
66
+
67
+ ## Durable turns
68
+
69
+ ```ts
70
+ import { openCodeDurable } from '@dudousxd/nestjs-agent-opencode/durable';
71
+
72
+ AgentModule.forRoot({ engine: openCodeDurable({ host: MyOpenCodeHost, sessions: MySessionStore }), store, sink, actorResolver })
73
+ // next to a configured DurableModule
74
+ ```
75
+
76
+ Every step of a turn is checkpointed (`begin → prompt → observe → [wait for a person → reply →
77
+ observe]* → finish`) and a person's decision is a durable signal: a turn waiting on an approval
78
+ survives restarts and is resumed by whichever process gets the decision. If OpenCode restarted
79
+ meanwhile, a new session is opened with the conversation and the decision. `openCode()` runs the
80
+ same steps in memory (single replica). Several processes need a cross-process sink and a persistent
81
+ `OpenCodeSessionStore`.
82
+
83
+ ## What the session gets from the module
84
+
85
+ | Option | In the session |
86
+ | --- | --- |
87
+ | `@Agent` / `@SystemPrompt` / contributors | instructions `aviary.system`, refreshed every turn |
88
+ | `approvalPolicy` | who approves each `permission.asked`, and its expiry; not required → answered at once |
89
+ | `tools: { url, secret }` | the module's `@AiTool`s over the engine's own MCP endpoint: reads allowed, actions asked, and run only against an approval |
90
+ | `skills` / `@Skill` | `.opencode/skills/<name>/SKILL.md` in the session's directory |
91
+ | `memory` | instructions `aviary.memory`; with `tools`, a `remember` tool when the provider writes (on the engine's endpoint only) |
92
+ | `ctx.emitUi` in a tool | the component lands in the turn's stream and message (see below) |
93
+ | `regenerate` | the session is reverted to before the last user message |
94
+
95
+ ## Tools over MCP
96
+
97
+ With `tools`, the engine mounts its own MCP endpoint at `POST <agent path>/opencode/mcp` and
98
+ registers it in every session (`mcp.add`) with a bearer token it mints: signed with `tools.secret`,
99
+ naming the turn's actor and the OpenCode server, expiring after `tools.ttlMs` (7 days; re-issued on a
100
+ kept session's turns once half-way through). Use the same `secret` in every process.
101
+
102
+ ```ts
103
+ AgentModule.forRoot({
104
+ engine: openCode({
105
+ host: MyOpenCodeHost,
106
+ // Where the OpenCode server reaches this app's endpoint — see "The tools URL" below.
107
+ tools: { url: process.env.OPENCODE_TOOLS_URL!, secret: process.env.OPENCODE_TOOLS_SECRET },
108
+ }),
109
+ memory: { provider }, // a provider with `write` → OpenCode gets `remember`
110
+ ...
111
+ }),
112
+ ```
113
+
114
+ ### The tools URL
115
+
116
+ `tools.url` has no default and is not derived from anything: the engine passes it verbatim to
117
+ OpenCode (`mcp.add` with `{ type: 'remote', url, headers: { Authorization: 'Bearer <token>' } }`),
118
+ and it is the **OpenCode server** — a separate process, often in another container or sandbox — that
119
+ calls it back. So it must be an address of this Nest app **as seen from the OpenCode server**, ending
120
+ in `<path>/opencode/mcp` (`path` is `AgentModule`'s route prefix, `agent` by default; a global prefix
121
+ is part of it too). There is no special hostname: `app.internal` in older examples was only a
122
+ placeholder. Keep it in an env variable (`OPENCODE_TOOLS_URL`) so each deployment sets its own.
123
+
124
+ | Where OpenCode runs | `OPENCODE_TOOLS_URL` |
125
+ | --- | --- |
126
+ | Same machine as the app | `http://127.0.0.1:3000/agent/opencode/mcp` |
127
+ | Docker Compose | the app's service name: `http://api:3000/agent/opencode/mcp` |
128
+ | Kubernetes | the app's Service DNS: `http://api.my-namespace.svc.cluster.local:3000/agent/opencode/mcp` |
129
+ | Anywhere else | the app's public URL works (`https://app.example.com/agent/opencode/mcp`) |
130
+
131
+ Prefer an internal network address: the endpoint only ever serves OpenCode, and a public URL puts it
132
+ on the internet (it stays guarded by the token, below). It must reach a process that mounts
133
+ controllers — `surface: 'engine'` mounts none. If several processes sit behind that address (or the
134
+ turns run on engine workers and the endpoint on HTTP pods), give them all the same `tools.secret`, so
135
+ a token signed by one is accepted by the others; without it each process signs with its own random
136
+ secret and the engine logs a warning.
137
+
138
+ The endpoint serves turns, nothing else. A token alone runs nothing:
139
+
140
+ - a call runs only while the token's actor has a turn running on the session the call names (OpenCode
141
+ puts it in `_meta`), on the token's server;
142
+ - the agent's (and persona's) allow-list, `enabled`, the roles policy and `canUse` apply, on
143
+ `tools/list` and on `tools/call`; the kinds only the loop serves are never offered;
144
+ - an `action` runs only against an approval granted in that turn (a person's, or the approval policy
145
+ saying none is needed): one call per approval. OpenCode's `ask` rules put the approval card in
146
+ front of the call; the endpoint checks it again, so a caller that skips OpenCode's rules (a model
147
+ in code mode that read the endpoint's headers) is refused;
148
+ - `remember` is served here only — it is not added to the module's registry, so it is not on
149
+ `AgentMcpServerModule` or the `/tools` catalog.
150
+
151
+ The module's `guards` are not applied to the endpoint (its callers are OpenCode sessions, not the
152
+ app's users); a global guard of your own must let `opencode/mcp` through.
153
+
154
+ ## Several processes
155
+
156
+ Use `openCodeDurable()`, a cross-process sink, and a shared session store:
157
+ `sessions: keyValueOpenCodeSessionStore(redis)`.
158
+
159
+ ## Testing against a real OpenCode
160
+
161
+ ```sh
162
+ OPENCODE_LIVE_URL=http://127.0.0.1:4096 OPENCODE_LIVE_PASSWORD=… \
163
+ OPENCODE_LIVE_MODEL=opencode-go/longcat-2.5-preview-free OPENCODE_LIVE_DIR=/tmp/work \
164
+ pnpm vitest run packages/opencode/src/live
165
+ ```
166
+
167
+ The server (`opencode serve`, 2.x, `OPENCODE_SERVER_PASSWORD` set) needs a key for the model's
168
+ provider (`integration.connect.key`). Without `OPENCODE_LIVE_URL` the live specs are skipped.
169
+
170
+ See `docs/design/2026-10-06-opencode-engine.md` for what is not wired yet.