@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 +20 -0
- package/dist/lifecycle.js +26 -0
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +57 -27
- package/package.json +2 -1
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":"
|
|
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"}
|
package/dist/providers/claude.js
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
}
|