@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 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 **intake** 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:
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 an intake on `agents.pi-exec.$USER`:
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: intake registered on agents.pi-exec.yourname
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
- - **Intake instance** (permanent): `agents.pi-exec.<owner>` — control plane
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.1 │ ABC123 │ intake
96
- │ │ DEF456 │ worker-1 — /home/mario/code/nats-zig
97
- │ │ GHI789 │ sid-gpt — /home/mario/code/sid-gpt
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). Run `pi /login` interactively once to populate it.
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
- ## Intake protocol
205
+ ## Control protocol
155
206
 
156
207
  Send structured JSON to `agents.pi-exec.<owner>`:
157
208
 
158
209
  ```typescript
159
- type IntakeRequest = {
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 intakeSubject(owner2) {
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 intakeService;
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(intake) {
197
- if (!intake.cwd) throw new Error("cwd is required");
198
- const absCwd = resolve(intake.cwd);
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 = intake.model ?? config.defaultModel;
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
- intake.thinkingLevel ?? config.defaultThinkingLevel
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 handleIntakeMessage(err, msg) {
260
+ function handleControlMessage(err, msg) {
261
261
  if (err) {
262
- process.stderr.write(`pi-exec: intake error: ${err.message}
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 intake;
270
+ let req;
271
271
  try {
272
- intake = JSON.parse(msg.string());
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 (!intake || typeof intake.sessionMode !== "string") {
277
+ if (!req || typeof req.sessionMode !== "string") {
278
278
  respondJson(msg, { error: "invalid_request", message: "sessionMode required" });
279
279
  return;
280
280
  }
281
- switch (intake.sessionMode) {
281
+ switch (req.sessionMode) {
282
282
  case "run":
283
- void handleRunMode(msg, intake);
283
+ void handleRunMode(msg, req);
284
284
  break;
285
285
  case "session":
286
- void handleSessionMode(msg, intake);
286
+ void handleSessionMode(msg, req);
287
287
  break;
288
288
  case "stop":
289
- void handleStopMode(msg, intake);
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: intake.sessionMode
297
+ sessionMode: req.sessionMode
298
298
  });
299
299
  }
300
300
  }
301
- async function handleRunMode(msg, intake) {
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 (!intake.body || !intake.cwd) {
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(intake);
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(intake.body);
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, intake) {
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 (!intake.body || !intake.cwd || !intake.sessionId) {
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(intake.sessionId);
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 !== intake.sessionId) {
369
+ if (sessionId !== req.sessionId) {
370
370
  respondJson(msg, {
371
371
  error: "invalid_sessionId",
372
- sessionId: intake.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(intake);
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: intake.maxLifetime ?? config.defaultMaxLifetime ?? DEFAULT_MAX_LIFETIME_SECONDS,
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: intake.from ?? "anonymous",
439
- body: intake.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, intake) {
454
- if (!intake.sessionId) {
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(intake.sessionId);
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 intakeService.stop();
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
- intakeService = await svcm.add({
670
+ controlService = await svcm.add({
671
671
  name: SERVICE_NAME,
672
672
  version: SERVICE_VERSION,
673
- description: "intake",
674
- metadata: { type: "intake", platform: "pi", owner },
673
+ description: "control",
674
+ metadata: { type: "control", platform: "pi", owner },
675
675
  queue: ""
676
676
  });
677
- intakeService.addEndpoint("intake", {
678
- subject: intakeSubject(owner),
679
- handler: handleIntakeMessage
677
+ controlService.addEndpoint("control", {
678
+ subject: controlSubject(owner),
679
+ handler: handleControlMessage
680
680
  });
681
- process.stderr.write(`pi-exec: intake registered on ${intakeSubject(owner)}
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.2",
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