@m64/nats-pi-bridge 0.0.2 → 0.0.4
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/README.md +63 -12
- package/dist/server.js +41 -41
- package/package.json +4 -5
- package/DESIGN.md +0 -350
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Standalone headless service that spawns and manages PI coding agent sessions on
|
|
|
6
6
|
>
|
|
7
7
|
> **All you need on the host:** Node.js ≥20.6, a NATS server (or `demo.nats.io`), and an API key for any supported model provider.
|
|
8
8
|
|
|
9
|
-
This bridge exposes session lifecycle + prompting as a `pi-exec` NATS microservice. Callers send structured JSON to a permanent **
|
|
9
|
+
This bridge exposes session lifecycle + prompting as a `pi-exec` NATS microservice. Callers send structured JSON to a permanent **control** endpoint to create, list, and stop sessions. Persistent sessions get their own per-session subject for follow-ups using the standard streaming wire protocol shared with the sibling implementations:
|
|
10
10
|
|
|
11
11
|
- `nats-pi-channel` — PI extension for **interactive** PI sessions
|
|
12
12
|
- `nats-claude-channel` — Claude Code MCP channel
|
|
@@ -34,12 +34,12 @@ In one terminal:
|
|
|
34
34
|
npx @m64/nats-pi-bridge
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
That's it. It connects to `demo.nats.io` by default and registers a `pi-exec` NATS microservice with
|
|
37
|
+
That's it. It connects to `demo.nats.io` by default and registers a `pi-exec` NATS microservice with a control endpoint on `agents.pi-exec.$USER`:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
40
|
pi-exec: connecting to demo.nats.io (context: default)
|
|
41
41
|
pi-exec: connected
|
|
42
|
-
pi-exec:
|
|
42
|
+
pi-exec: control registered on agents.pi-exec.yourname
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
### 2. Fire a one-shot prompt
|
|
@@ -81,22 +81,73 @@ Ctrl-C the bridge when you're done. Every active session is disposed during grac
|
|
|
81
81
|
|
|
82
82
|
## Architecture
|
|
83
83
|
|
|
84
|
-
See [DESIGN.md](./DESIGN.md) for the full architectural document.
|
|
85
|
-
|
|
86
84
|
The bridge runs as a single Node.js process. The `pi-exec` service has:
|
|
87
85
|
|
|
88
|
-
- **
|
|
86
|
+
- **Control instance** (permanent): `agents.pi-exec.<owner>` — control plane
|
|
89
87
|
- **Per-session instances** (dynamic): `agents.pi-exec.<owner>.<sessionId>` — data plane
|
|
90
88
|
|
|
91
89
|
Each session is its own NATS microservice instance (the same pattern that `nats-pi-channel` uses across processes — but here within one process). On stop, the session's instance is removed cleanly via `service.stop()`.
|
|
92
90
|
|
|
93
91
|
```
|
|
94
92
|
nats micro list
|
|
95
|
-
→ pi-exec │ 0.0.
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
→ pi-exec │ 0.0.4 │ ABC123 │ control
|
|
94
|
+
│ │ DEF456 │ worker-1 — /home/mario/code/nats-zig
|
|
95
|
+
│ │ GHI789 │ sid-gpt — /home/mario/code/sid-gpt
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## NATS server
|
|
99
|
+
|
|
100
|
+
The Quick Start uses `demo.nats.io` because it's zero-setup, but you'll usually want your own server. The bridge picks the server from a [NATS CLI context](https://docs.nats.io/using-nats/nats-tools/nats_cli#contexts) named via the `NATS_CONTEXT` environment variable.
|
|
101
|
+
|
|
102
|
+
### Localhost
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# 1. One-time: create a context pointing at your local NATS
|
|
106
|
+
nats context save local --server nats://localhost:4222
|
|
107
|
+
|
|
108
|
+
# 2. Run the bridge against it
|
|
109
|
+
NATS_CONTEXT=local npx @m64/nats-pi-bridge
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
pi-exec: connecting to nats://localhost:4222 (context: local)
|
|
114
|
+
pi-exec: connected
|
|
115
|
+
pi-exec: control registered on agents.pi-exec.m64
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **Don't have a local NATS server yet?** The fastest way:
|
|
119
|
+
>
|
|
120
|
+
> ```bash
|
|
121
|
+
> docker run --rm -p 4222:4222 nats:latest
|
|
122
|
+
> ```
|
|
123
|
+
>
|
|
124
|
+
> Or grab a binary from the [nats-server releases](https://github.com/nats-io/nats-server/releases).
|
|
125
|
+
|
|
126
|
+
### Make a context the default
|
|
127
|
+
|
|
128
|
+
To avoid setting `NATS_CONTEXT` on every invocation, drop a one-line config file:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
mkdir -p ~/.pi-exec
|
|
132
|
+
echo '{"context":"local"}' > ~/.pi-exec/config.json
|
|
98
133
|
```
|
|
99
134
|
|
|
135
|
+
After that, plain `npx @m64/nats-pi-bridge` connects to your chosen server.
|
|
136
|
+
|
|
137
|
+
### Authenticated and remote servers
|
|
138
|
+
|
|
139
|
+
The same context format works for any NATS auth scheme — credentials files, NKeys, JWTs, TLS, user/password. Anything `nats context save` can store, the bridge can use:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
nats context save prod \
|
|
143
|
+
--server nats://nats.example.com:4222 \
|
|
144
|
+
--creds ~/.nkeys/creds/synadia/MyAccount/me.creds
|
|
145
|
+
|
|
146
|
+
NATS_CONTEXT=prod npx @m64/nats-pi-bridge
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Context files live at `~/.config/nats/context/<name>.json`. See the [NATS CLI context docs](https://docs.nats.io/using-nats/nats-tools/nats_cli#contexts) for the full list of supported fields.
|
|
150
|
+
|
|
100
151
|
## Install
|
|
101
152
|
|
|
102
153
|
```bash
|
|
@@ -149,14 +200,14 @@ Environment overrides:
|
|
|
149
200
|
| `PI_EXEC_DEFAULT_MODEL` | Default model spec, e.g. `anthropic/claude-sonnet-4-5` |
|
|
150
201
|
| `PI_EXEC_DEFAULT_MAX_LIFETIME` | Default max session lifetime in seconds |
|
|
151
202
|
|
|
152
|
-
PI credentials are read from `~/.pi/agent/auth.json` (the standard PI agent location).
|
|
203
|
+
PI credentials are read from `~/.pi/agent/auth.json` (the standard PI agent location). Create the file directly — see the [Quick Start prerequisite](#quick-start) for the one-liner. If you have PI installed locally, `pi /login` populates it automatically.
|
|
153
204
|
|
|
154
|
-
##
|
|
205
|
+
## Control protocol
|
|
155
206
|
|
|
156
207
|
Send structured JSON to `agents.pi-exec.<owner>`:
|
|
157
208
|
|
|
158
209
|
```typescript
|
|
159
|
-
type
|
|
210
|
+
type ControlRequest = {
|
|
160
211
|
sessionMode: "run" | "session" | "stop" | "list";
|
|
161
212
|
body?: string; // prompt text (run/session)
|
|
162
213
|
cwd?: string; // working directory (run/session) — resolved to absolute
|
package/dist/server.js
CHANGED
|
@@ -34,7 +34,7 @@ var DEFAULT_CONTEXT = {
|
|
|
34
34
|
url: "demo.nats.io",
|
|
35
35
|
description: "NATS demo server (no auth)"
|
|
36
36
|
};
|
|
37
|
-
function
|
|
37
|
+
function controlSubject(owner2) {
|
|
38
38
|
return `agents.pi-exec.${owner2}`;
|
|
39
39
|
}
|
|
40
40
|
function sessionSubject(owner2, sessionId) {
|
|
@@ -171,7 +171,7 @@ var requestCounter = 0;
|
|
|
171
171
|
var sessions = /* @__PURE__ */ new Map();
|
|
172
172
|
var creating = /* @__PURE__ */ new Set();
|
|
173
173
|
var nc;
|
|
174
|
-
var
|
|
174
|
+
var controlService;
|
|
175
175
|
var owner;
|
|
176
176
|
var config;
|
|
177
177
|
var authStorage;
|
|
@@ -193,17 +193,17 @@ function validateThinkingLevel(level) {
|
|
|
193
193
|
`invalid thinkingLevel: ${level} (must be one of ${VALID_THINKING_LEVELS.join(", ")})`
|
|
194
194
|
);
|
|
195
195
|
}
|
|
196
|
-
async function createPiSession(
|
|
197
|
-
if (!
|
|
198
|
-
const absCwd = resolve(
|
|
196
|
+
async function createPiSession(req) {
|
|
197
|
+
if (!req.cwd) throw new Error("cwd is required");
|
|
198
|
+
const absCwd = resolve(req.cwd);
|
|
199
199
|
if (!existsSync(absCwd) || !statSync(absCwd).isDirectory()) {
|
|
200
200
|
throw new Error(`cwd not found or not a directory: ${absCwd}`);
|
|
201
201
|
}
|
|
202
|
-
const modelSpec =
|
|
202
|
+
const modelSpec = req.model ?? config.defaultModel;
|
|
203
203
|
const model = resolveModel(modelSpec);
|
|
204
204
|
if (modelSpec && !model) throw new Error(`unknown model: ${modelSpec}`);
|
|
205
205
|
const thinkingLevel = validateThinkingLevel(
|
|
206
|
-
|
|
206
|
+
req.thinkingLevel ?? config.defaultThinkingLevel
|
|
207
207
|
);
|
|
208
208
|
const { session } = await createAgentSession({
|
|
209
209
|
cwd: absCwd,
|
|
@@ -257,9 +257,9 @@ function respondJson(msg, payload) {
|
|
|
257
257
|
} catch {
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
-
function
|
|
260
|
+
function handleControlMessage(err, msg) {
|
|
261
261
|
if (err) {
|
|
262
|
-
process.stderr.write(`pi-exec:
|
|
262
|
+
process.stderr.write(`pi-exec: control error: ${err.message}
|
|
263
263
|
`);
|
|
264
264
|
return;
|
|
265
265
|
}
|
|
@@ -267,26 +267,26 @@ function handleIntakeMessage(err, msg) {
|
|
|
267
267
|
respondJson(msg, { error: "shutting_down" });
|
|
268
268
|
return;
|
|
269
269
|
}
|
|
270
|
-
let
|
|
270
|
+
let req;
|
|
271
271
|
try {
|
|
272
|
-
|
|
272
|
+
req = JSON.parse(msg.string());
|
|
273
273
|
} catch (e) {
|
|
274
274
|
respondJson(msg, { error: "invalid_json", message: e.message });
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
|
-
if (!
|
|
277
|
+
if (!req || typeof req.sessionMode !== "string") {
|
|
278
278
|
respondJson(msg, { error: "invalid_request", message: "sessionMode required" });
|
|
279
279
|
return;
|
|
280
280
|
}
|
|
281
|
-
switch (
|
|
281
|
+
switch (req.sessionMode) {
|
|
282
282
|
case "run":
|
|
283
|
-
void handleRunMode(msg,
|
|
283
|
+
void handleRunMode(msg, req);
|
|
284
284
|
break;
|
|
285
285
|
case "session":
|
|
286
|
-
void handleSessionMode(msg,
|
|
286
|
+
void handleSessionMode(msg, req);
|
|
287
287
|
break;
|
|
288
288
|
case "stop":
|
|
289
|
-
void handleStopMode(msg,
|
|
289
|
+
void handleStopMode(msg, req);
|
|
290
290
|
break;
|
|
291
291
|
case "list":
|
|
292
292
|
handleListMode(msg);
|
|
@@ -294,31 +294,31 @@ function handleIntakeMessage(err, msg) {
|
|
|
294
294
|
default:
|
|
295
295
|
respondJson(msg, {
|
|
296
296
|
error: "invalid_sessionMode",
|
|
297
|
-
sessionMode:
|
|
297
|
+
sessionMode: req.sessionMode
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
|
-
async function handleRunMode(msg,
|
|
301
|
+
async function handleRunMode(msg, req) {
|
|
302
302
|
const reply = msg.reply;
|
|
303
303
|
if (!reply) {
|
|
304
304
|
respondJson(msg, { error: "no_reply_subject" });
|
|
305
305
|
return;
|
|
306
306
|
}
|
|
307
|
-
if (!
|
|
307
|
+
if (!req.body || !req.cwd) {
|
|
308
308
|
respondJson(msg, { error: "missing_fields", required: ["body", "cwd"] });
|
|
309
309
|
return;
|
|
310
310
|
}
|
|
311
311
|
let session;
|
|
312
312
|
let unsubscribe;
|
|
313
313
|
try {
|
|
314
|
-
const created = await createPiSession(
|
|
314
|
+
const created = await createPiSession(req);
|
|
315
315
|
session = created.session;
|
|
316
316
|
unsubscribe = session.subscribe((ev) => {
|
|
317
317
|
if (ev.type === "message_update" && ev.assistantMessageEvent.type === "text_delta" && ev.assistantMessageEvent.delta) {
|
|
318
318
|
publishChunked(nc, reply, ev.assistantMessageEvent.delta);
|
|
319
319
|
}
|
|
320
320
|
});
|
|
321
|
-
await session.prompt(
|
|
321
|
+
await session.prompt(req.body);
|
|
322
322
|
} catch (e) {
|
|
323
323
|
try {
|
|
324
324
|
nc.publish(reply, `error: ${e.message}`);
|
|
@@ -345,20 +345,20 @@ async function handleRunMode(msg, intake) {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
}
|
|
348
|
-
async function handleSessionMode(msg,
|
|
348
|
+
async function handleSessionMode(msg, req) {
|
|
349
349
|
const reply = msg.reply;
|
|
350
350
|
if (!reply) {
|
|
351
351
|
respondJson(msg, { error: "no_reply_subject" });
|
|
352
352
|
return;
|
|
353
353
|
}
|
|
354
|
-
if (!
|
|
354
|
+
if (!req.body || !req.cwd || !req.sessionId) {
|
|
355
355
|
respondJson(msg, {
|
|
356
356
|
error: "missing_fields",
|
|
357
357
|
required: ["body", "cwd", "sessionId"]
|
|
358
358
|
});
|
|
359
359
|
return;
|
|
360
360
|
}
|
|
361
|
-
const sessionId = sanitizeSessionName(
|
|
361
|
+
const sessionId = sanitizeSessionName(req.sessionId);
|
|
362
362
|
if (!sessionId) {
|
|
363
363
|
respondJson(msg, {
|
|
364
364
|
error: "invalid_sessionId",
|
|
@@ -366,10 +366,10 @@ async function handleSessionMode(msg, intake) {
|
|
|
366
366
|
});
|
|
367
367
|
return;
|
|
368
368
|
}
|
|
369
|
-
if (sessionId !==
|
|
369
|
+
if (sessionId !== req.sessionId) {
|
|
370
370
|
respondJson(msg, {
|
|
371
371
|
error: "invalid_sessionId",
|
|
372
|
-
sessionId:
|
|
372
|
+
sessionId: req.sessionId,
|
|
373
373
|
message: `sessionId must match [a-z0-9_-]+; suggested: ${sessionId}`
|
|
374
374
|
});
|
|
375
375
|
return;
|
|
@@ -386,7 +386,7 @@ async function handleSessionMode(msg, intake) {
|
|
|
386
386
|
creating.add(sessionId);
|
|
387
387
|
let created;
|
|
388
388
|
try {
|
|
389
|
-
created = await createPiSession(
|
|
389
|
+
created = await createPiSession(req);
|
|
390
390
|
} catch (e) {
|
|
391
391
|
creating.delete(sessionId);
|
|
392
392
|
respondJson(msg, {
|
|
@@ -420,7 +420,7 @@ async function handleSessionMode(msg, intake) {
|
|
|
420
420
|
thinkingLevel: created.thinkingLevel,
|
|
421
421
|
createdAt: Date.now(),
|
|
422
422
|
lastActivity: Date.now(),
|
|
423
|
-
maxLifetime:
|
|
423
|
+
maxLifetime: req.maxLifetime ?? config.defaultMaxLifetime ?? DEFAULT_MAX_LIFETIME_SECONDS,
|
|
424
424
|
subject,
|
|
425
425
|
pendingRequests: /* @__PURE__ */ new Map(),
|
|
426
426
|
requestQueue: [],
|
|
@@ -435,8 +435,8 @@ async function handleSessionMode(msg, intake) {
|
|
|
435
435
|
managed.pendingRequests.set(initialId, {
|
|
436
436
|
requestId: initialId,
|
|
437
437
|
replySubject: reply,
|
|
438
|
-
from:
|
|
439
|
-
body:
|
|
438
|
+
from: req.from ?? "anonymous",
|
|
439
|
+
body: req.body,
|
|
440
440
|
createdAt: Date.now()
|
|
441
441
|
});
|
|
442
442
|
managed.requestQueue.push(initialId);
|
|
@@ -450,12 +450,12 @@ async function handleSessionMode(msg, intake) {
|
|
|
450
450
|
initialId
|
|
451
451
|
);
|
|
452
452
|
}
|
|
453
|
-
async function handleStopMode(msg,
|
|
454
|
-
if (!
|
|
453
|
+
async function handleStopMode(msg, req) {
|
|
454
|
+
if (!req.sessionId) {
|
|
455
455
|
respondJson(msg, { error: "missing_fields", required: ["sessionId"] });
|
|
456
456
|
return;
|
|
457
457
|
}
|
|
458
|
-
const sid = sanitizeSessionName(
|
|
458
|
+
const sid = sanitizeSessionName(req.sessionId);
|
|
459
459
|
const managed = sessions.get(sid);
|
|
460
460
|
if (!managed) {
|
|
461
461
|
respondJson(msg, { error: "not_found", sessionId: sid });
|
|
@@ -623,7 +623,7 @@ async function shutdown(signal) {
|
|
|
623
623
|
clearInterval(pruneInterval);
|
|
624
624
|
await Promise.allSettled(Array.from(sessions.values()).map((m) => disposeSession(m)));
|
|
625
625
|
try {
|
|
626
|
-
await
|
|
626
|
+
await controlService.stop();
|
|
627
627
|
} catch {
|
|
628
628
|
}
|
|
629
629
|
try {
|
|
@@ -667,18 +667,18 @@ owner = sanitizeSessionName(process.env.USER ?? "unknown") || "unknown";
|
|
|
667
667
|
authStorage = AuthStorage.create();
|
|
668
668
|
modelRegistry = ModelRegistry.create(authStorage);
|
|
669
669
|
var svcm = new Svcm(nc);
|
|
670
|
-
|
|
670
|
+
controlService = await svcm.add({
|
|
671
671
|
name: SERVICE_NAME,
|
|
672
672
|
version: SERVICE_VERSION,
|
|
673
|
-
description: "
|
|
674
|
-
metadata: { type: "
|
|
673
|
+
description: "control",
|
|
674
|
+
metadata: { type: "control", platform: "pi", owner },
|
|
675
675
|
queue: ""
|
|
676
676
|
});
|
|
677
|
-
|
|
678
|
-
subject:
|
|
679
|
-
handler:
|
|
677
|
+
controlService.addEndpoint("control", {
|
|
678
|
+
subject: controlSubject(owner),
|
|
679
|
+
handler: handleControlMessage
|
|
680
680
|
});
|
|
681
|
-
process.stderr.write(`pi-exec:
|
|
681
|
+
process.stderr.write(`pi-exec: control registered on ${controlSubject(owner)}
|
|
682
682
|
`);
|
|
683
683
|
lifetimeInterval = setInterval(checkLifetimes, LIFETIME_CHECK_INTERVAL_MS);
|
|
684
684
|
lifetimeInterval.unref();
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m64/nats-pi-bridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Standalone headless service that spawns and manages PI coding agent sessions on demand via NATS. Control plane / data plane split, streaming wire protocol, multi-session microservice registration.",
|
|
5
5
|
"author": "m64",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
-
"homepage": "",
|
|
7
|
+
"homepage": "https://github.com/M64GitHub/nats-pi-bridge#readme",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": ""
|
|
10
|
+
"url": "git+https://github.com/M64GitHub/nats-pi-bridge.git"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": ""
|
|
13
|
+
"url": "https://github.com/M64GitHub/nats-pi-bridge/issues"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
16
|
"engines": {
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
-
"DESIGN.md",
|
|
35
34
|
"README.md",
|
|
36
35
|
"LICENSE"
|
|
37
36
|
],
|
package/DESIGN.md
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
# nats-pi-bridge — Design Document
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
Standalone headless service that spawns and manages PI coding agent sessions on demand via NATS. Callers send structured JSON commands to an **intake endpoint** (control plane) to create, run, and stop sessions. Persistent sessions also expose **per-session endpoints** (data plane) for follow-up prompts using the standard streaming wire protocol.
|
|
6
|
-
|
|
7
|
-
Part of the Synadia NATS AI Agents ecosystem alongside:
|
|
8
|
-
- `@m64/nats-channel` — OpenClaw channel plugin (loads into OpenClaw)
|
|
9
|
-
- `nats-claude-channel` — Claude Code channel (loads into Claude Code via MCP)
|
|
10
|
-
- `@m64/nats-pi-channel` — PI extension (loads into interactive PI sessions)
|
|
11
|
-
|
|
12
|
-
This bridge is different: it does NOT load into PI. It embeds PI headlessly via the SDK.
|
|
13
|
-
|
|
14
|
-
## Architecture
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
18
|
-
│ nats-pi-bridge (standalone service) │
|
|
19
|
-
│ │
|
|
20
|
-
│ Service: pi-exec │
|
|
21
|
-
│ │
|
|
22
|
-
│ CONTROL PLANE │
|
|
23
|
-
│ ───────────── │
|
|
24
|
-
│ Intake endpoint: agents.pi-exec.<owner> │
|
|
25
|
-
│ Structured JSON commands: run, session, stop, list │
|
|
26
|
-
│ │
|
|
27
|
-
│ DATA PLANE │
|
|
28
|
-
│ ────────── │
|
|
29
|
-
│ Per-session endpoints (registered on demand): │
|
|
30
|
-
│ agents.pi-exec.<owner>.<sessionId> │
|
|
31
|
-
│ Wire protocol: plain text or {from,body}, streaming │
|
|
32
|
-
│ chunks, empty payload = done │
|
|
33
|
-
│ │
|
|
34
|
-
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
|
35
|
-
│ │ abc123 │ │ def456 │ │ (ephem) │ │
|
|
36
|
-
│ │ nats-zig│ │ sid-gpt │ │ run cmd │ │
|
|
37
|
-
│ │ persist │ │ persist │ │ no endpt│ │
|
|
38
|
-
│ └─────────┘ └─────────┘ └─────────┘ │
|
|
39
|
-
│ │
|
|
40
|
-
│ sessions: Map<sessionId, ManagedSession> │
|
|
41
|
-
└──────────────────────────────────────────────────────────────┘
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Key Design Decisions
|
|
45
|
-
|
|
46
|
-
### Control plane vs data plane separation
|
|
47
|
-
|
|
48
|
-
The intake endpoint (`agents.pi-exec.<owner>`) is the **control plane**. It handles session lifecycle: create, stop, list. It accepts structured JSON with a `sessionMode` field.
|
|
49
|
-
|
|
50
|
-
Per-session endpoints (`agents.pi-exec.<owner>.<sessionId>`) are the **data plane**. They handle prompts using the standard wire protocol shared with all sibling implementations (plain text or `{from, body}` envelope, streaming chunks, empty = done).
|
|
51
|
-
|
|
52
|
-
**No mixing.** Sending a `sessionMode: "session"` command to the intake for an already-existing session is rejected. Follow-up prompts go to the per-session endpoint. This eliminates ambiguity around field handling (what if `cwd` or `model` differ on reuse?).
|
|
53
|
-
|
|
54
|
-
### Separate microservice name
|
|
55
|
-
|
|
56
|
-
The bridge registers as `pi-exec`, not `pi-channel`. This cleanly separates headless/dynamic sessions from interactive PI sessions:
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
nats micro list
|
|
60
|
-
→ pi-channel (2 instances) ← interactive (human at keyboard)
|
|
61
|
-
→ pi-exec (3 instances) ← headless (bridge-managed)
|
|
62
|
-
→ claude-channel (1 instance) ← Claude Code
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
No namespace collision, no awkward subject prefixes, no cross-service confusion.
|
|
66
|
-
|
|
67
|
-
### Caller-owned session naming
|
|
68
|
-
|
|
69
|
-
The caller decides the `sessionId`. UUIDs, hashes, descriptive names — whatever the caller's automation needs. The bridge does not do collision detection on session names. If the sessionId already exists, the intake rejects with the session's endpoint subject so the caller can redirect.
|
|
70
|
-
|
|
71
|
-
**`sessionId` is required on `session` mode requests.** There is no auto-generation — callers must pick and track their own IDs. The intake rejects missing IDs with `{error:"missing_fields", required:[..., "sessionId"]}`, and rejects IDs that would change under sanitization (`[a-z0-9_-]+`) with `{error:"invalid_sessionId", sessionId, suggested}` so the caller knows exactly what to send next time.
|
|
72
|
-
|
|
73
|
-
Rationale: the caller addresses the per-session subject by `sessionId`, so it must know the exact value up front — returning a generated ID in the first stream chunk would force every caller to parse a response envelope before the real agent output arrives, which breaks the uniform wire protocol shared with the sibling implementations.
|
|
74
|
-
|
|
75
|
-
### Subject convention
|
|
76
|
-
|
|
77
|
-
```
|
|
78
|
-
agents.pi-exec.<owner> ← intake (permanent)
|
|
79
|
-
agents.pi-exec.<owner>.<sessionId> ← per-session (dynamic)
|
|
80
|
-
agents.pi-exec.<owner>.<sessionId>.inspect ← per-session inspect
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
- `owner`: from `$USER`, sanitized (same as pi-channel / claude-channel)
|
|
84
|
-
- No `org` field for now. Can add later if needed.
|
|
85
|
-
|
|
86
|
-
## Session Modes
|
|
87
|
-
|
|
88
|
-
Single field `sessionMode` on the intake request. Four values, no invalid combinations.
|
|
89
|
-
|
|
90
|
-
### `run` (default, simplest case)
|
|
91
|
-
|
|
92
|
-
Ephemeral single-shot execution. No endpoint registered, no discoverability, no follow-ups.
|
|
93
|
-
|
|
94
|
-
```json
|
|
95
|
-
{
|
|
96
|
-
"sessionMode": "run",
|
|
97
|
-
"body": "List all TODO comments in the codebase",
|
|
98
|
-
"cwd": "/home/mario/code/nats-zig",
|
|
99
|
-
"from": "ci-bot"
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Flow:
|
|
104
|
-
1. Create PI session with `SessionManager.inMemory()`
|
|
105
|
-
2. Subscribe to events
|
|
106
|
-
3. Call `session.prompt(body)`
|
|
107
|
-
4. Stream `text_delta` chunks to NATS reply subject
|
|
108
|
-
5. On `agent_end`, publish empty payload (done)
|
|
109
|
-
6. `session.dispose()`
|
|
110
|
-
|
|
111
|
-
No sessionId needed. No tracking. Fire and forget.
|
|
112
|
-
|
|
113
|
-
### `session` (persistent, addressable)
|
|
114
|
-
|
|
115
|
-
Creates a long-lived session with a per-session endpoint for follow-ups.
|
|
116
|
-
|
|
117
|
-
```json
|
|
118
|
-
{
|
|
119
|
-
"sessionMode": "session",
|
|
120
|
-
"body": "Run tests and fix failures",
|
|
121
|
-
"cwd": "/home/mario/code/nats-zig",
|
|
122
|
-
"sessionId": "my-worker-1",
|
|
123
|
-
"from": "orchestrator",
|
|
124
|
-
"model": "anthropic/claude-sonnet-4-5",
|
|
125
|
-
"thinkingLevel": "medium",
|
|
126
|
-
"maxLifetime": 3600
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Flow:
|
|
131
|
-
1. Validate required fields (`body`, `cwd`, `sessionId`) and sanitize `sessionId`; reject if any are missing or the ID mutates under sanitization
|
|
132
|
-
2. Check if `sessionId` already exists in sessions Map
|
|
133
|
-
3. If exists → **reject** with error response including the session's endpoint subject
|
|
134
|
-
4. If new → create PI session, register a dedicated `pi-exec` microservice instance for this session, add to Map
|
|
135
|
-
5. Run the initial prompt, stream response on the intake's reply subject
|
|
136
|
-
6. Session stays alive for follow-ups via the per-session endpoint; the per-session service instance is torn down via `service.stop()` on `stop` mode or lifetime expiry
|
|
137
|
-
|
|
138
|
-
On reuse rejection:
|
|
139
|
-
```json
|
|
140
|
-
{
|
|
141
|
-
"error": "session_exists",
|
|
142
|
-
"sessionId": "my-worker-1",
|
|
143
|
-
"subject": "agents.pi-exec.mario.my-worker-1",
|
|
144
|
-
"message": "Session already exists. Send follow-up prompts to the session subject."
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Fields only used on creation: `cwd`, `model`, `thinkingLevel`, `maxLifetime`.
|
|
149
|
-
|
|
150
|
-
### `stop`
|
|
151
|
-
|
|
152
|
-
Disposes a session, removes its endpoint.
|
|
153
|
-
|
|
154
|
-
```json
|
|
155
|
-
{
|
|
156
|
-
"sessionMode": "stop",
|
|
157
|
-
"sessionId": "my-worker-1"
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Flow:
|
|
162
|
-
1. Look up `sessionId` in sessions Map
|
|
163
|
-
2. If not found → error response
|
|
164
|
-
3. If found → `session.dispose()`, remove endpoint, remove inspect subscription, delete from Map
|
|
165
|
-
4. Respond with confirmation (non-streaming, single message)
|
|
166
|
-
|
|
167
|
-
### `list`
|
|
168
|
-
|
|
169
|
-
Returns all active sessions.
|
|
170
|
-
|
|
171
|
-
```json
|
|
172
|
-
{
|
|
173
|
-
"sessionMode": "list"
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
Response (non-streaming, single JSON message):
|
|
178
|
-
```json
|
|
179
|
-
{
|
|
180
|
-
"sessions": [
|
|
181
|
-
{
|
|
182
|
-
"sessionId": "my-worker-1",
|
|
183
|
-
"subject": "agents.pi-exec.mario.my-worker-1",
|
|
184
|
-
"cwd": "/home/mario/code/nats-zig",
|
|
185
|
-
"createdAt": "2026-04-08T14:30:00Z",
|
|
186
|
-
"lastActivity": "2026-04-08T14:35:22Z",
|
|
187
|
-
"maxLifetime": 3600,
|
|
188
|
-
"remainingLifetime": 3278,
|
|
189
|
-
"activeRequest": false,
|
|
190
|
-
"queuedRequests": 0
|
|
191
|
-
}
|
|
192
|
-
]
|
|
193
|
-
}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
## Intake protocol summary
|
|
197
|
-
|
|
198
|
-
| sessionMode | Session exists? | Behavior |
|
|
199
|
-
|---|---|---|
|
|
200
|
-
| `run` | n/a | ephemeral: create, prompt, stream, dispose |
|
|
201
|
-
| `session` | no | create, register endpoint, prompt, stream |
|
|
202
|
-
| `session` | yes | **reject** with session info |
|
|
203
|
-
| `stop` | yes | dispose, confirm |
|
|
204
|
-
| `stop` | no | error: not found |
|
|
205
|
-
| `list` | n/a | return all sessions |
|
|
206
|
-
|
|
207
|
-
## Per-session endpoint protocol
|
|
208
|
-
|
|
209
|
-
Identical to the standard wire protocol used by all sibling implementations:
|
|
210
|
-
|
|
211
|
-
- **Inbound**: plain text or JSON envelope `{"from":"sender","body":"text"}`
|
|
212
|
-
- **Outbound**: streaming text chunks on reply subject
|
|
213
|
-
- **Completion**: empty payload on reply subject
|
|
214
|
-
- **Usage**: `nats req agents.pi-exec.mario.my-worker-1 "Fix the linting errors" --wait-for-empty --timeout 120s`
|
|
215
|
-
|
|
216
|
-
Requests to per-session endpoints are queued and processed serially (one prompt at a time per session).
|
|
217
|
-
|
|
218
|
-
## PI SDK integration
|
|
219
|
-
|
|
220
|
-
Each session is an independent `AgentSession` created via `createAgentSession()`:
|
|
221
|
-
|
|
222
|
-
```typescript
|
|
223
|
-
const { session } = await createAgentSession({
|
|
224
|
-
cwd: request.cwd,
|
|
225
|
-
sessionManager: SessionManager.inMemory(), // run mode
|
|
226
|
-
// or: SessionManager.open(sessionFile), // session mode
|
|
227
|
-
authStorage,
|
|
228
|
-
modelRegistry,
|
|
229
|
-
model: resolvedModel,
|
|
230
|
-
thinkingLevel: request.thinkingLevel ?? "off",
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
Streaming via `session.subscribe()`:
|
|
235
|
-
|
|
236
|
-
```typescript
|
|
237
|
-
session.subscribe((event) => {
|
|
238
|
-
if (event.type === "message_update" &&
|
|
239
|
-
event.assistantMessageEvent.type === "text_delta") {
|
|
240
|
-
nc.publish(replySubject, event.assistantMessageEvent.delta);
|
|
241
|
-
}
|
|
242
|
-
if (event.type === "agent_end") {
|
|
243
|
-
nc.publish(replySubject, ""); // done
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
await session.prompt(body);
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
Multiple sessions in one process is supported — OpenClaw does this in production.
|
|
251
|
-
|
|
252
|
-
## Session lifecycle & safety
|
|
253
|
-
|
|
254
|
-
### Max lifetime
|
|
255
|
-
|
|
256
|
-
Optional `maxLifetime` field in seconds (default: 1800 = 30 minutes). Background interval checks the sessions Map and disposes expired sessions, removes their endpoints. Prevents orphaned sessions if a caller forgets to `stop`.
|
|
257
|
-
|
|
258
|
-
### Stale request pruning
|
|
259
|
-
|
|
260
|
-
Same pattern as claude-channel and pi-channel: 60-second interval, prune pending requests older than 30 minutes.
|
|
261
|
-
|
|
262
|
-
### Graceful shutdown
|
|
263
|
-
|
|
264
|
-
On process SIGTERM/SIGINT:
|
|
265
|
-
1. Stop accepting new intake requests
|
|
266
|
-
2. For each managed session: `session.dispose()`
|
|
267
|
-
3. For each service endpoint: remove
|
|
268
|
-
4. `service.stop()` → `nc.drain()`
|
|
269
|
-
|
|
270
|
-
## Internal data structures
|
|
271
|
-
|
|
272
|
-
```typescript
|
|
273
|
-
type ManagedSession = {
|
|
274
|
-
session: AgentSession;
|
|
275
|
-
sessionId: string;
|
|
276
|
-
cwd: string;
|
|
277
|
-
createdAt: number;
|
|
278
|
-
lastActivity: number;
|
|
279
|
-
maxLifetime: number; // seconds, 0 = no expiry
|
|
280
|
-
endpoint: ServiceEndpoint;
|
|
281
|
-
inspectSub: Subscription;
|
|
282
|
-
requestQueue: string[]; // queued request IDs
|
|
283
|
-
activeRequestId: string | null;
|
|
284
|
-
pendingRequests: Map<string, PendingRequest>;
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
type PendingRequest = {
|
|
288
|
-
replySubject: string;
|
|
289
|
-
from: string;
|
|
290
|
-
createdAt: number;
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const sessions = new Map<string, ManagedSession>();
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
## Discovery
|
|
297
|
-
|
|
298
|
-
```bash
|
|
299
|
-
# List all services
|
|
300
|
-
nats micro list
|
|
301
|
-
→ pi-exec (1 instance, N endpoints)
|
|
302
|
-
|
|
303
|
-
# Detailed info — shows intake + all session endpoints
|
|
304
|
-
nats micro info pi-exec
|
|
305
|
-
→ intake: agents.pi-exec.mario
|
|
306
|
-
→ session: agents.pi-exec.mario.my-worker-1 │ /home/mario/code/nats-zig
|
|
307
|
-
→ session: agents.pi-exec.mario.sid-gpt-abc │ /home/mario/code/sid-gpt
|
|
308
|
-
|
|
309
|
-
# Inspect a specific session
|
|
310
|
-
nats req agents.pi-exec.mario.my-worker-1.inspect "" --timeout 5s
|
|
311
|
-
→ {"name":"my-worker-1","description":"PI agent in /home/mario/code/nats-zig",...}
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
## Configuration
|
|
315
|
-
|
|
316
|
-
Config file: `~/.pi-exec/config.json` (separate from pi-channel config)
|
|
317
|
-
|
|
318
|
-
```json
|
|
319
|
-
{
|
|
320
|
-
"context": "my-nats-context",
|
|
321
|
-
"defaultModel": "anthropic/claude-sonnet-4-5",
|
|
322
|
-
"defaultThinkingLevel": "off",
|
|
323
|
-
"defaultMaxLifetime": 1800
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
Environment variable overrides:
|
|
328
|
-
- `NATS_CONTEXT` — NATS CLI context name
|
|
329
|
-
- `PI_EXEC_DEFAULT_MODEL` — default model
|
|
330
|
-
- `PI_EXEC_DEFAULT_MAX_LIFETIME` — default max lifetime in seconds
|
|
331
|
-
|
|
332
|
-
NATS context loading: same pattern as all sibling implementations — reads from `~/.config/nats/context/<n>.json`.
|
|
333
|
-
|
|
334
|
-
## What is NOT in v1
|
|
335
|
-
|
|
336
|
-
- No org namespace support (add later if needed)
|
|
337
|
-
- No encryption / E2E
|
|
338
|
-
- No A2A protocol
|
|
339
|
-
- No session persistence to disk (sessions are in-memory, lost on bridge restart)
|
|
340
|
-
- No authentication on the intake (NATS auth handles access control)
|
|
341
|
-
- No model hot-swap on existing sessions
|
|
342
|
-
- No resource limits (max concurrent sessions, memory caps)
|
|
343
|
-
|
|
344
|
-
## Future considerations
|
|
345
|
-
|
|
346
|
-
- **Session persistence**: Use `SessionManager.open(path)` instead of `.inMemory()` so sessions survive bridge restarts
|
|
347
|
-
- **Org support**: Add `org` field to intake, extend subject to `agents.pi-exec.<org>.<owner>.<sessionId>`
|
|
348
|
-
- **Resource limits**: Max sessions per bridge, max concurrent prompts across all sessions
|
|
349
|
-
- **Pub/sub notifications**: Publish session lifecycle events (created, stopped, expired) to a notification subject for monitoring
|
|
350
|
-
- **Multi-bridge coordination**: Multiple bridge instances sharing session state via NATS KV — JetStream as the session registry
|