@glubean/port 0.1.1 → 0.2.0

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
@@ -86,6 +86,26 @@ const result = await port.turns.startStructured({
86
86
  console.log(result.data)
87
87
  ```
88
88
 
89
+ Streaming partial JSON: when callers want progressive structure (rather than
90
+ the all-or-nothing `startStructured` contract), accumulate `message.delta.text`
91
+ and feed it to a partial-JSON parser. [`partial-json`](https://www.npmjs.com/package/partial-json)
92
+ is a zero-dependency option designed for LLM streaming:
93
+
94
+ ```ts
95
+ import { parse, Allow } from "partial-json"
96
+
97
+ let accumulated = ""
98
+ for await (const event of port.turns.start({ sessionId: session.id, input })) {
99
+ if (event.type !== "message.delta") continue
100
+ accumulated += event.text
101
+ const partial = parse(accumulated, Allow.ALL)
102
+ // push `partial` to the UI as it grows
103
+ }
104
+ ```
105
+
106
+ Use `startStructured` when you need final-shape validation or retry on malformed
107
+ output; use the partial-JSON pattern when you need to render incrementally.
108
+
89
109
  ## Local Development
90
110
 
91
111
  Node 24 can run the TypeScript sources directly.
package/dist/lifecycle.js CHANGED
@@ -104,6 +104,10 @@ class LifecycleRuntime {
104
104
  const ref = eventToRef(runtime.#adapter.id, event, input.sessionId);
105
105
  if (ref && !currentTurnId)
106
106
  currentTurnId = ref.turnId;
107
+ // Set terminalSeen BEFORE yielding so that if the consumer
108
+ // breaks on the terminal event, the finally below does not
109
+ // append a spurious cancellation. Same reasoning applies to
110
+ // the synthesized terminals further down.
107
111
  if (event.type === "turn.completed")
108
112
  terminalSeen = true;
109
113
  runtime.#record(event, input);
@@ -111,6 +115,7 @@ class LifecycleRuntime {
111
115
  }
112
116
  if (currentTurnId && !terminalSeen) {
113
117
  const completed = completedEvent(input.sessionId, currentTurnId, "completed");
118
+ terminalSeen = true;
114
119
  runtime.#record(completed, input);
115
120
  yield completed;
116
121
  }
@@ -120,6 +125,7 @@ class LifecycleRuntime {
120
125
  if (currentTurnId) {
121
126
  const errorEvent = { type: "error", sessionId: input.sessionId, turnId: currentTurnId, message };
122
127
  const completed = completedEvent(input.sessionId, currentTurnId, "failed", message);
128
+ terminalSeen = true;
123
129
  runtime.#record(errorEvent, input);
124
130
  runtime.#record(completed, input);
125
131
  yield errorEvent;
@@ -127,6 +133,26 @@ class LifecycleRuntime {
127
133
  }
128
134
  throw error;
129
135
  }
136
+ finally {
137
+ // If the consumer abandons the iterator after the turn was accepted
138
+ // but before a terminal event, synthesize a cancellation so
139
+ // turns.status / turns.wait don't report "running" forever, and
140
+ // ALWAYS poke the adapter to stop work for that turn. We can't
141
+ // tell from inside lifecycle whether a previously-recorded error
142
+ // event was a terminal failure (claude spawn ENOENT) or a benign
143
+ // diagnostic (codex/gemini stderr surfaced as error) — so we
144
+ // treat the consumer's break as the authoritative termination
145
+ // signal, preserving the recorded error in the snapshot for
146
+ // informational purposes.
147
+ if (currentTurnId && !terminalSeen) {
148
+ const recorded = runtime.#turns.get(turnKey(input.sessionId, currentTurnId));
149
+ if (!recorded || !isTerminalStatus(recorded.status)) {
150
+ const cancelled = completedEvent(input.sessionId, currentTurnId, "cancelled", recorded?.error);
151
+ runtime.#record(cancelled, input);
152
+ void runtime.#adapter.cancelTurn({ sessionId: input.sessionId, turnId: currentTurnId }).catch(() => undefined);
153
+ }
154
+ }
155
+ }
130
156
  },
131
157
  };
132
158
  }
@@ -1 +1 @@
1
- {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/providers/claude.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAGV,aAAa,EACb,kBAAkB,EAGlB,IAAI,EACJ,cAAc,EAEd,cAAc,EAEf,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAGpD,KAAK,iBAAiB,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;AAEtD,eAAO,MAAM,kBAAkB,EAAE,aAAa,CAAC,QAAQ,CAwCtD,CAAC;AAEF,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAE1F;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAGxG;AA0ID,wBAAgB,eAAe,CAAC,KAAK,EAAE,cAAc,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAe9G"}
1
+ {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/providers/claude.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAGV,aAAa,EACb,kBAAkB,EAGlB,IAAI,EACJ,cAAc,EAEd,cAAc,EAEf,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAGpD,KAAK,iBAAiB,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;AAEtD,eAAO,MAAM,kBAAkB,EAAE,aAAa,CAAC,QAAQ,CAwCtD,CAAC;AAEF,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAE1F;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAGxG;AA2KD,wBAAgB,eAAe,CAAC,KAAK,EAAE,cAAc,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAe9G"}
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { EventEmitter } from "node:events";
4
4
  import process from "node:process";
5
+ import { AsyncQueue } from "../async-queue.js";
5
6
  import { createPortFromAdapter } from "../port.js";
6
7
  import { providerRuntimeOptions, resolveRuntimeOptions } from "../options.js";
7
8
  import { normalizeTokenUsage } from "../token-usage.js";
@@ -93,14 +94,23 @@ class ClaudeRuntime {
93
94
  });
94
95
  this.#processes.set(turnId, child);
95
96
  child.stdin.end(inputToPrompt(input.input));
97
+ const queue = new AsyncQueue();
96
98
  let buffered = "";
97
99
  let sawMessage = false;
98
100
  const stderr = [];
99
- const events = [];
100
- const push = (event) => {
101
- events.push(event);
101
+ let exitInfo = { code: null, signal: null };
102
+ let spawnError;
103
+ const emit = (event) => {
104
+ queue.push(event);
102
105
  this.#events.emit("event", event);
103
106
  };
107
+ const drainLine = (line) => {
108
+ for (const event of normalizeClaudeLine(line, input.sessionId, turnId, sawMessage)) {
109
+ if (event.type === "message.delta")
110
+ sawMessage = true;
111
+ emit(event);
112
+ }
113
+ };
104
114
  child.stdout.setEncoding("utf8");
105
115
  child.stdout.on("data", (chunk) => {
106
116
  buffered += chunk;
@@ -112,34 +122,57 @@ class ClaudeRuntime {
112
122
  buffered = buffered.slice(newlineIndex + 1);
113
123
  if (!line)
114
124
  continue;
115
- for (const event of normalizeClaudeLine(line, input.sessionId, turnId, sawMessage)) {
116
- if (event.type === "message.delta")
117
- sawMessage = true;
118
- push(event);
119
- }
125
+ drainLine(line);
120
126
  }
121
127
  });
122
128
  child.stderr.setEncoding("utf8");
123
129
  child.stderr.on("data", (chunk) => stderr.push(chunk));
124
- const exit = await waitForExit(child);
125
- this.#processes.delete(turnId);
126
- const trailing = buffered.trim();
127
- if (trailing) {
128
- for (const event of normalizeClaudeLine(trailing, input.sessionId, turnId, sawMessage)) {
129
- if (event.type === "message.delta")
130
- sawMessage = true;
131
- push(event);
130
+ // Delete from #processes only once the OS reports the child is gone
131
+ // (close fires after stdio is drained; error covers spawn failures).
132
+ // Doing this in handlers keeps the map entry reachable for
133
+ // adapter.close() / cancelTurn() during the gap between SIGTERM and
134
+ // actual exit, in case the binary ignores or delays the signal.
135
+ child.on("error", (err) => {
136
+ spawnError = err;
137
+ this.#processes.delete(turnId);
138
+ queue.close();
139
+ });
140
+ child.on("close", (code, signal) => {
141
+ exitInfo = { code, signal };
142
+ const trailing = buffered.trim();
143
+ buffered = "";
144
+ if (trailing)
145
+ drainLine(trailing);
146
+ this.#processes.delete(turnId);
147
+ queue.close();
148
+ });
149
+ try {
150
+ for await (const event of queue)
151
+ yield event;
152
+ if (spawnError) {
153
+ const message = spawnError.message;
154
+ yield { type: "error", sessionId: input.sessionId, turnId, message };
155
+ yield completedTurn(input.sessionId, turnId, "failed", message);
156
+ return;
157
+ }
158
+ if (exitInfo.code === 0) {
159
+ yield completedTurn(input.sessionId, turnId, "completed");
160
+ return;
132
161
  }
162
+ const message = stderr.join("").trim() || `Claude Code exited with code ${exitInfo.code ?? "null"} signal=${exitInfo.signal ?? "null"}`;
163
+ yield { type: "error", sessionId: input.sessionId, turnId, message };
164
+ yield completedTurn(input.sessionId, turnId, "failed", message);
133
165
  }
134
- for (const event of events)
135
- yield event;
136
- if (exit.code === 0) {
137
- yield completedTurn(input.sessionId, turnId, "completed");
138
- return;
166
+ finally {
167
+ // If the consumer aborts the iterator while the child is still
168
+ // alive, send SIGTERM. The close handler above is the single owner
169
+ // of the #processes entry and will delete it once the kernel
170
+ // confirms exit — so callers retain a working handle for graceful
171
+ // shutdown (close()) or escalation (cancelTurn → repeat kill).
172
+ if (child.exitCode === null && child.signalCode === null) {
173
+ child.kill();
174
+ }
139
175
  }
140
- const message = stderr.join("").trim() || `Claude Code exited with code ${exit.code ?? "null"} signal=${exit.signal ?? "null"}`;
141
- yield { type: "error", sessionId: input.sessionId, turnId, message };
142
- yield completedTurn(input.sessionId, turnId, "failed", message);
143
176
  }
144
177
  async #cancelTurn(input) {
145
178
  this.#processes.get(input.turnId)?.kill();
@@ -284,9 +317,6 @@ function textFromClaudeMessage(value) {
284
317
  return text ? [text] : [];
285
318
  });
286
319
  }
287
- function waitForExit(child) {
288
- return new Promise((resolve) => child.on("exit", (code, signal) => resolve({ code, signal })));
289
- }
290
320
  function asRecord(value) {
291
321
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
292
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glubean/port",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Neutral runtime adapter for local coding agents.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^25.6.0",
32
+ "partial-json": "^0.1.7",
32
33
  "typescript": "^6.0.3",
33
34
  "vitest": "^4.1.5"
34
35
  }