@celilo/core 0.3.2 → 0.5.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/package.json +1 -1
- package/src/command-registry.ts +19 -0
- package/src/protocol.ts +64 -3
- package/src/remote-client.ts +195 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Lightweight shared core for Celilo CLI tools — command registry, NDJSON API protocol, and remote SSH client. No Ink/React/drizzle/aws-sdk transitive deps, so an MCP server can reach the transport without installing the full CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/command-registry.ts
CHANGED
|
@@ -168,6 +168,11 @@ export const COMMANDS: CommandDef[] = [
|
|
|
168
168
|
{ name: 'limit', description: 'Max rows to return', takesValue: true },
|
|
169
169
|
],
|
|
170
170
|
},
|
|
171
|
+
{
|
|
172
|
+
name: 'list-unanswered',
|
|
173
|
+
description: 'List interview questions nobody has answered yet',
|
|
174
|
+
flags: [{ name: 'limit', description: 'Max rows to return', takesValue: true }],
|
|
175
|
+
},
|
|
171
176
|
{
|
|
172
177
|
name: 'drain',
|
|
173
178
|
description: 'Process pending deliveries once and return',
|
|
@@ -306,6 +311,12 @@ export const COMMANDS: CommandDef[] = [
|
|
|
306
311
|
description: 'Remove the installed supervisor unit',
|
|
307
312
|
flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
|
|
308
313
|
},
|
|
314
|
+
{
|
|
315
|
+
name: 'restart-daemon',
|
|
316
|
+
description:
|
|
317
|
+
'Restart the dispatcher through its supervisor, stopping any unsupervised one, and verify the new process reports the installed code version',
|
|
318
|
+
flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
|
|
319
|
+
},
|
|
309
320
|
{
|
|
310
321
|
name: 'show-daemon',
|
|
311
322
|
description: 'Print the currently installed unit file',
|
|
@@ -1218,6 +1229,14 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1218
1229
|
{
|
|
1219
1230
|
name: 'migrate',
|
|
1220
1231
|
description: 'Apply pending database migrations (idempotent; safe to re-run)',
|
|
1232
|
+
flags: [
|
|
1233
|
+
{
|
|
1234
|
+
name: 'status',
|
|
1235
|
+
description:
|
|
1236
|
+
'Report applied count, latest applied migration and pending ones by name without applying anything',
|
|
1237
|
+
takesValue: false,
|
|
1238
|
+
},
|
|
1239
|
+
],
|
|
1221
1240
|
},
|
|
1222
1241
|
{
|
|
1223
1242
|
name: 'doctor',
|
package/src/protocol.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A versioned, Zod-validated tagged union carried as NDJSON. This is the
|
|
5
5
|
* transport-neutral message layer for driving the CLI over the wire — see
|
|
6
|
-
* `openspec/changes/replace-ssh-cli-api/proposal.md
|
|
7
|
-
*
|
|
6
|
+
* `openspec/changes/replace-ssh-cli-api/proposal.md` and
|
|
7
|
+
* `openspec/changes/park-unanswerable-interviews/proposal.md`.
|
|
8
8
|
*
|
|
9
9
|
* The command crosses the wire as a *structured, validated* `argv` array —
|
|
10
10
|
* never a shell string handed to `exec`.
|
|
@@ -31,10 +31,45 @@ export const AnswerMessageSchema = z.object({
|
|
|
31
31
|
});
|
|
32
32
|
export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* "I cannot decide this" — the client had no way to reach a decider (no TTY, no
|
|
36
|
+
* pre-staged answer) for the `interview` with this `id`.
|
|
37
|
+
*
|
|
38
|
+
* It is deliberately NOT an `answer`: the server must not resolve the question,
|
|
39
|
+
* because a resolution of any shape consumes it and nobody else can then answer.
|
|
40
|
+
* The server parks the command and replies `blocked` (celilo#609).
|
|
41
|
+
*/
|
|
42
|
+
export const UnanswerableMessageSchema = z.object({
|
|
43
|
+
type: z.literal('unanswerable'),
|
|
44
|
+
id: z.string(),
|
|
45
|
+
reason: z.string(),
|
|
46
|
+
});
|
|
47
|
+
export type UnanswerableMessage = z.infer<typeof UnanswerableMessageSchema>;
|
|
48
|
+
|
|
49
|
+
/** Re-join a parked session's output stream. Authorized against its owner. */
|
|
50
|
+
export const AttachMessageSchema = z.object({
|
|
51
|
+
type: z.literal('attach'),
|
|
52
|
+
sessionId: z.string(),
|
|
53
|
+
});
|
|
54
|
+
export type AttachMessage = z.infer<typeof AttachMessageSchema>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Deliberately abandon a parked session before its TTL expires: the outstanding
|
|
58
|
+
* query is answered `abandoned`, the command fails naming what was never
|
|
59
|
+
* decided, and the record is retained.
|
|
60
|
+
*/
|
|
61
|
+
export const CancelMessageSchema = z.object({
|
|
62
|
+
type: z.literal('cancel'),
|
|
63
|
+
sessionId: z.string(),
|
|
64
|
+
});
|
|
65
|
+
export type CancelMessage = z.infer<typeof CancelMessageSchema>;
|
|
66
|
+
|
|
34
67
|
export const ClientMessageSchema = z.discriminatedUnion('type', [
|
|
35
68
|
CommandMessageSchema,
|
|
36
69
|
AnswerMessageSchema,
|
|
37
|
-
|
|
70
|
+
UnanswerableMessageSchema,
|
|
71
|
+
AttachMessageSchema,
|
|
72
|
+
CancelMessageSchema,
|
|
38
73
|
]);
|
|
39
74
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
40
75
|
|
|
@@ -91,6 +126,14 @@ export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
|
|
|
91
126
|
export const InterviewMessageSchema = z.object({
|
|
92
127
|
type: z.literal('interview'),
|
|
93
128
|
id: z.string(),
|
|
129
|
+
/**
|
|
130
|
+
* The question's stable identity (`interview.required.<scope>.<key>`). A
|
|
131
|
+
* client can pre-stage an answer under `<scope>.<key>` — the same lookup key
|
|
132
|
+
* `celilo events respond --values` uses. Optional so a client still parses
|
|
133
|
+
* messages from an older server that didn't send them.
|
|
134
|
+
*/
|
|
135
|
+
scope: z.string().optional(),
|
|
136
|
+
key: z.string().optional(),
|
|
94
137
|
kind: z.enum(['text', 'confirm', 'select', 'multiselect']),
|
|
95
138
|
message: z.string(),
|
|
96
139
|
description: z.string().optional(),
|
|
@@ -103,6 +146,23 @@ export const InterviewMessageSchema = z.object({
|
|
|
103
146
|
});
|
|
104
147
|
export type InterviewMessage = z.infer<typeof InterviewMessageSchema>;
|
|
105
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Terminal-for-now: the command is parked on a question this client cannot
|
|
151
|
+
* decide. The command is still alive server-side under `sessionId`; answer
|
|
152
|
+
* `eventId` (`celilo events reply <eventId> <value>`) and `attach` to collect
|
|
153
|
+
* the outcome. Not a failure and not a decline — nothing was decided yet.
|
|
154
|
+
*/
|
|
155
|
+
export const BlockedMessageSchema = z.object({
|
|
156
|
+
type: z.literal('blocked'),
|
|
157
|
+
sessionId: z.string(),
|
|
158
|
+
/** Bus event id of the unanswered query, as a string. */
|
|
159
|
+
eventId: z.string(),
|
|
160
|
+
question: z.string(),
|
|
161
|
+
/** The question's `<scope>.<key>`, when the server sent one. */
|
|
162
|
+
key: z.string().optional(),
|
|
163
|
+
});
|
|
164
|
+
export type BlockedMessage = z.infer<typeof BlockedMessageSchema>;
|
|
165
|
+
|
|
106
166
|
export const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
107
167
|
ReadyMessageSchema,
|
|
108
168
|
ProgressMessageSchema,
|
|
@@ -110,6 +170,7 @@ export const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
|
110
170
|
ResultMessageSchema,
|
|
111
171
|
ErrorMessageSchema,
|
|
112
172
|
InterviewMessageSchema,
|
|
173
|
+
BlockedMessageSchema,
|
|
113
174
|
]);
|
|
114
175
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
115
176
|
|
package/src/remote-client.ts
CHANGED
|
@@ -12,12 +12,51 @@ import { spawn } from 'node:child_process';
|
|
|
12
12
|
import { Readable } from 'node:stream';
|
|
13
13
|
import { type DisplayWriter, ProgressDisplay } from '@celilo/cli-display';
|
|
14
14
|
import * as clack from '@clack/prompts';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type ClientMessage,
|
|
17
|
+
type InterviewMessage,
|
|
18
|
+
type ServerMessage,
|
|
19
|
+
ServerMessageSchema,
|
|
20
|
+
} from './protocol';
|
|
16
21
|
|
|
17
22
|
const REMOTE_FLAG = '--remote';
|
|
18
23
|
|
|
19
|
-
/**
|
|
24
|
+
/** Stable `<scope>.<key>` identity for a forwarded question, when the server sent one. */
|
|
25
|
+
export function interviewKey(iv: InterviewMessage): string | null {
|
|
26
|
+
return iv.scope && iv.key ? `${iv.scope}.${iv.key}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thrown by a renderer that has no way to reach a decider. `runRemoteClient`
|
|
31
|
+
* turns it into an `unanswerable` message — never an `answer` — so the server
|
|
32
|
+
* parks the command with the question still standing rather than resolving it
|
|
33
|
+
* (to a default, or to anything else) on nobody's authority.
|
|
34
|
+
*/
|
|
35
|
+
export class InterviewUnanswerableError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'InterviewUnanswerableError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a forwarded interview via clack, returning the operator's answer.
|
|
44
|
+
*
|
|
45
|
+
* Refuses to prompt when stdin isn't a TTY. clack reads keypresses off stdin
|
|
46
|
+
* whether or not it is a terminal, so on a piped stdin (an MCP server's
|
|
47
|
+
* JSON-RPC stream, a CI harness) the very next newline submits the prompt at
|
|
48
|
+
* its `initialValue` — i.e. the question's `defaultValue` — and the answer
|
|
49
|
+
* looks exactly like a considered human decision. That is how a live rollout
|
|
50
|
+
* came to report "operator declined" for a breaking update nobody was asked
|
|
51
|
+
* about. A client with no terminal must say so instead of guessing.
|
|
52
|
+
*/
|
|
20
53
|
async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
55
|
+
const key = interviewKey(iv);
|
|
56
|
+
throw new InterviewUnanswerableError(
|
|
57
|
+
`Cannot answer "${iv.message}"${key ? ` (${key})` : ''}: stdin isn't a terminal and no answer was pre-staged.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
21
60
|
const options = (iv.options ?? []).map((o) => ({ value: o.value, label: o.label, hint: o.hint }));
|
|
22
61
|
let result: unknown;
|
|
23
62
|
switch (iv.kind) {
|
|
@@ -53,7 +92,15 @@ async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
|
|
|
53
92
|
|
|
54
93
|
/** Minimal transport surface — satisfied by a `node:child_process` spawn of `ssh`. */
|
|
55
94
|
export interface RemoteTransport {
|
|
56
|
-
stdin: {
|
|
95
|
+
stdin: {
|
|
96
|
+
write(chunk: string): void;
|
|
97
|
+
flush?(): number | Promise<number>;
|
|
98
|
+
/**
|
|
99
|
+
* Close our end without tearing the server down. Used when a command parks:
|
|
100
|
+
* killing the transport kills the session we specifically want to survive.
|
|
101
|
+
*/
|
|
102
|
+
end?(): void;
|
|
103
|
+
};
|
|
57
104
|
stdout: ReadableStream<Uint8Array>;
|
|
58
105
|
kill(): void;
|
|
59
106
|
exited: Promise<number>;
|
|
@@ -88,7 +135,10 @@ function openSshTransport(dest: string): RemoteTransport {
|
|
|
88
135
|
const proc = spawn('ssh', ['-T', dest], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
89
136
|
if (!proc.stdout || !proc.stdin) throw new Error('ssh was not spawned with piped stdio');
|
|
90
137
|
return {
|
|
91
|
-
stdin: {
|
|
138
|
+
stdin: {
|
|
139
|
+
write: (chunk: string) => void proc.stdin?.write(chunk),
|
|
140
|
+
end: () => void proc.stdin?.end(),
|
|
141
|
+
},
|
|
92
142
|
stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
93
143
|
kill: () => void proc.kill(),
|
|
94
144
|
exited: new Promise<number>((resolve) => proc.once('exit', (code) => resolve(code ?? 0))),
|
|
@@ -138,6 +188,7 @@ function applyMessage(
|
|
|
138
188
|
process.stderr.write(`${msg.error}\n`);
|
|
139
189
|
return null;
|
|
140
190
|
case 'interview':
|
|
191
|
+
case 'blocked':
|
|
141
192
|
// Handled by the caller (needs the transport to reply) — never reached here.
|
|
142
193
|
return null;
|
|
143
194
|
case 'result':
|
|
@@ -145,36 +196,40 @@ function applyMessage(
|
|
|
145
196
|
}
|
|
146
197
|
}
|
|
147
198
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
): Promise<number> {
|
|
157
|
-
if (argv.length === 0) {
|
|
158
|
-
process.stderr.write(
|
|
159
|
-
'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
|
|
160
|
-
);
|
|
161
|
-
return 2;
|
|
162
|
-
}
|
|
199
|
+
/**
|
|
200
|
+
* How a remote run ended. `blocked` is not a failure and not a decline: the
|
|
201
|
+
* command is parked server-side on a question this client could not decide, and
|
|
202
|
+
* is still alive under `sessionId`.
|
|
203
|
+
*/
|
|
204
|
+
export type RemoteOutcome =
|
|
205
|
+
| { status: 'result'; exitCode: number }
|
|
206
|
+
| { status: 'blocked'; sessionId: string; eventId: string; question: string; key?: string };
|
|
163
207
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const renderInterview = opts.renderInterview ?? defaultRenderInterview;
|
|
167
|
-
const display = new ProgressDisplay({ out });
|
|
208
|
+
/** Exit code a caller reports when a command parked rather than finishing. */
|
|
209
|
+
export const EXIT_BLOCKED = 75; // EX_TEMPFAIL — try again once it's answered.
|
|
168
210
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
211
|
+
interface StreamOptions {
|
|
212
|
+
out: DisplayWriter;
|
|
213
|
+
display: ProgressDisplay;
|
|
214
|
+
renderInterview: (interview: InterviewMessage) => Promise<unknown>;
|
|
215
|
+
/**
|
|
216
|
+
* What to do with a `blocked` message. A caller that *is* a decider (an
|
|
217
|
+
* operator at a terminal) keeps waiting; a headless one returns so it can
|
|
218
|
+
* answer out-of-band and attach later.
|
|
219
|
+
*/
|
|
220
|
+
onBlocked: 'return' | 'wait';
|
|
221
|
+
}
|
|
172
222
|
|
|
173
|
-
|
|
223
|
+
/** Read the server's NDJSON stream until it goes terminal. */
|
|
224
|
+
async function consumeStream(
|
|
225
|
+
transport: RemoteTransport,
|
|
226
|
+
opts: StreamOptions,
|
|
227
|
+
): Promise<RemoteOutcome | null> {
|
|
228
|
+
const { out, display, renderInterview } = opts;
|
|
174
229
|
const decoder = new TextDecoder();
|
|
175
230
|
let buffer = '';
|
|
176
231
|
|
|
177
|
-
|
|
232
|
+
for await (const chunk of transport.stdout) {
|
|
178
233
|
buffer += decoder.decode(chunk, { stream: true });
|
|
179
234
|
let nl = buffer.indexOf('\n');
|
|
180
235
|
while (nl >= 0) {
|
|
@@ -193,26 +248,126 @@ export async function runRemoteClient(
|
|
|
193
248
|
}
|
|
194
249
|
|
|
195
250
|
if (msg.type === 'interview') {
|
|
196
|
-
|
|
197
|
-
|
|
251
|
+
let reply: string;
|
|
252
|
+
try {
|
|
253
|
+
const value = await renderInterview(msg);
|
|
254
|
+
reply = JSON.stringify({ type: 'answer', id: msg.id, value });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// Never fabricate an answer, and never resolve the question at all:
|
|
257
|
+
// say we can't decide and let the server park it.
|
|
258
|
+
reply = JSON.stringify({
|
|
259
|
+
type: 'unanswerable',
|
|
260
|
+
id: msg.id,
|
|
261
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
transport.stdin.write(`${reply}\n`);
|
|
198
265
|
await transport.stdin.flush?.();
|
|
199
266
|
continue;
|
|
200
267
|
}
|
|
201
268
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
269
|
+
if (msg.type === 'blocked') {
|
|
270
|
+
if (opts.onBlocked === 'wait') {
|
|
271
|
+
out.write(
|
|
272
|
+
`Waiting on: ${msg.question} (answer with: celilo events reply ${msg.eventId} <value>)\n`,
|
|
273
|
+
);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
status: 'blocked',
|
|
278
|
+
sessionId: msg.sessionId,
|
|
279
|
+
eventId: msg.eventId,
|
|
280
|
+
question: msg.question,
|
|
281
|
+
key: msg.key,
|
|
282
|
+
};
|
|
206
283
|
}
|
|
284
|
+
|
|
285
|
+
const exit = applyMessage(msg, display, out);
|
|
286
|
+
if (exit !== null) return { status: 'result', exitCode: exit };
|
|
207
287
|
}
|
|
208
288
|
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface RemoteClientOptions {
|
|
293
|
+
openTransport?: (dest: string) => RemoteTransport;
|
|
294
|
+
out?: DisplayWriter;
|
|
295
|
+
renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
|
|
296
|
+
onBlocked?: 'return' | 'wait';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function runRemoteClient(
|
|
300
|
+
dest: string,
|
|
301
|
+
argv: string[],
|
|
302
|
+
opts: RemoteClientOptions = {},
|
|
303
|
+
): Promise<RemoteOutcome> {
|
|
304
|
+
if (argv.length === 0) {
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
|
|
307
|
+
);
|
|
308
|
+
return { status: 'result', exitCode: 2 };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return openAndConsume(dest, { type: 'command', argv }, opts);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Re-join a parked session: replays the output produced while nobody was
|
|
316
|
+
* attached, then streams the rest through to the terminal `result`.
|
|
317
|
+
*/
|
|
318
|
+
export function attachRemoteClient(
|
|
319
|
+
dest: string,
|
|
320
|
+
sessionId: string,
|
|
321
|
+
opts: RemoteClientOptions = {},
|
|
322
|
+
): Promise<RemoteOutcome> {
|
|
323
|
+
return openAndConsume(dest, { type: 'attach', sessionId }, opts);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Deliberately abandon a parked session: its outstanding question is answered
|
|
328
|
+
* `abandoned` and the command fails naming what was never decided. The
|
|
329
|
+
* operator-initiated counterpart to the TTL reaper.
|
|
330
|
+
*/
|
|
331
|
+
export function cancelRemoteClient(
|
|
332
|
+
dest: string,
|
|
333
|
+
sessionId: string,
|
|
334
|
+
opts: RemoteClientOptions = {},
|
|
335
|
+
): Promise<RemoteOutcome> {
|
|
336
|
+
return openAndConsume(dest, { type: 'cancel', sessionId }, opts);
|
|
337
|
+
}
|
|
209
338
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
339
|
+
/** Open a transport, send one opening message, and consume until terminal. */
|
|
340
|
+
async function openAndConsume(
|
|
341
|
+
dest: string,
|
|
342
|
+
opening: ClientMessage,
|
|
343
|
+
opts: RemoteClientOptions,
|
|
344
|
+
): Promise<RemoteOutcome> {
|
|
345
|
+
const openTransport = opts.openTransport ?? openSshTransport;
|
|
346
|
+
const out = opts.out ?? process.stdout;
|
|
347
|
+
const transport = openTransport(dest);
|
|
348
|
+
transport.stdin.write(`${JSON.stringify(opening)}\n`);
|
|
349
|
+
await transport.stdin.flush?.();
|
|
350
|
+
|
|
351
|
+
const outcome = await consumeStream(transport, {
|
|
352
|
+
out,
|
|
353
|
+
display: new ProgressDisplay({ out }),
|
|
354
|
+
renderInterview: opts.renderInterview ?? defaultRenderInterview,
|
|
355
|
+
// A terminal operator is a decider, so they wait rather than being handed a
|
|
356
|
+
// session id to answer out-of-band.
|
|
357
|
+
onBlocked: opts.onBlocked ?? (process.stdin.isTTY ? 'wait' : 'return'),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
if (outcome?.status === 'blocked') {
|
|
361
|
+
// Do NOT kill: the command is parked and must outlive us. Close our stdin
|
|
362
|
+
// and leave; the server keeps the child and buffers its output.
|
|
363
|
+
transport.stdin.end?.();
|
|
364
|
+
return outcome;
|
|
214
365
|
}
|
|
215
366
|
|
|
216
|
-
//
|
|
217
|
-
|
|
367
|
+
// Server is still looping awaiting more input — tear our end down.
|
|
368
|
+
transport.kill();
|
|
369
|
+
if (outcome) return outcome;
|
|
370
|
+
|
|
371
|
+
// Stream ended without a terminal message — surface the transport's exit.
|
|
372
|
+
return { status: 'result', exitCode: await transport.exited };
|
|
218
373
|
}
|