larkin 0.5.8 → 0.5.10

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.
@@ -30,7 +30,7 @@ async function runClaudeControl(call) {
30
30
  };
31
31
  const timer = setTimeout(() => finish(new Error("Claude model catalog timed out")), call.timeout);
32
32
  timer.unref?.();
33
- child.on("error", () => finish(new Error("Claude model catalog command failed")));
33
+ child.on("error", (error) => finish(new Error(`Claude model catalog command failed: ${error.message}`)));
34
34
  child.on("exit", () => finish(new Error("Claude model catalog control channel exited before response")));
35
35
  child.stderr.resume();
36
36
  child.stdout.on("data", (chunk) => {
@@ -27,7 +27,7 @@ async function runClaudeControl(call) {
27
27
  };
28
28
  const timer = setTimeout(() => finish(new Error("Claude model catalog timed out")), call.timeout);
29
29
  timer.unref?.();
30
- child.on("error", () => finish(new Error("Claude model catalog command failed")));
30
+ child.on("error", (error) => finish(new Error(`Claude model catalog command failed: ${error.message}`)));
31
31
  child.on("exit", () => finish(new Error("Claude model catalog control channel exited before response")));
32
32
  child.stderr.resume();
33
33
  child.stdout.on("data", (chunk) => {
@@ -31,7 +31,7 @@ async function runCodexAppServer(call) {
31
31
  };
32
32
  const timer = setTimeout(() => finish(new Error("Codex model catalog timed out")), call.timeout);
33
33
  timer.unref?.();
34
- child.on("error", () => finish(new Error("Codex model catalog command failed")));
34
+ child.on("error", (error) => finish(new Error(`Codex model catalog command failed: ${error.message}`)));
35
35
  child.on("exit", () => finish(new Error("Codex model catalog app-server exited before response")));
36
36
  child.stderr.resume();
37
37
  child.stdout.on("data", (chunk) => {
@@ -28,7 +28,7 @@ async function runCodexAppServer(call) {
28
28
  };
29
29
  const timer = setTimeout(() => finish(new Error("Codex model catalog timed out")), call.timeout);
30
30
  timer.unref?.();
31
- child.on("error", () => finish(new Error("Codex model catalog command failed")));
31
+ child.on("error", (error) => finish(new Error(`Codex model catalog command failed: ${error.message}`)));
32
32
  child.on("exit", () => finish(new Error("Codex model catalog app-server exited before response")));
33
33
  child.stderr.resume();
34
34
  child.stdout.on("data", (chunk) => {
@@ -47,11 +47,15 @@ class PiRpcClient {
47
47
  this.stderrBuffer = Buffer.concat([this.stderrBuffer, value.subarray(0, this.maxStderrBytes - this.stderrBuffer.length)]);
48
48
  });
49
49
  process.once("error", (error) => this.fail(new Error(`Pi RPC process failed: ${error.message}`), true));
50
- process.once("exit", (code, signal) => {
50
+ const settle = (code, signal) => {
51
51
  this.exited = true;
52
52
  const stderr = this.stderrBuffer.toString("utf8").trim().slice(0, 2_000);
53
53
  this.fail(new Error(`Pi RPC process exited (${code ?? signal ?? "unknown"})${stderr ? `: ${stderr}` : ""}`), false);
54
- });
54
+ };
55
+ process.once("exit", settle);
56
+ // A spawn that never started emits "error" + "close" and never "exit", so
57
+ // "close" is the only terminal event Node guarantees for failed spawns.
58
+ process.once("close", settle);
55
59
  }
56
60
  subscribe(listener) {
57
61
  this.eventListeners.add(listener);
@@ -89,11 +93,11 @@ class PiRpcClient {
89
93
  throw new Error("stdin is unavailable");
90
94
  this.process.stdin.write(`${JSON.stringify({ id, type: command, ...fields })}\n`, (error) => {
91
95
  if (error)
92
- this.fail(new Error(`Pi RPC ${command} write failed: ${error.message}`), true);
96
+ this.failWrite(command, error);
93
97
  });
94
98
  }
95
99
  catch (error) {
96
- this.fail(new Error(`Pi RPC ${command} write failed: ${error instanceof Error ? error.message : String(error)}`), true);
100
+ this.failWrite(command, error);
97
101
  }
98
102
  });
99
103
  }
@@ -210,6 +214,24 @@ class PiRpcClient {
210
214
  protocolFailure(detail) {
211
215
  this.fail(new Error(`Pi RPC protocol error: ${detail}`), true);
212
216
  }
217
+ /**
218
+ * A failed write matters less than why the process is gone: a spawn failure or
219
+ * an early exit explains it, and that verdict is queued on the next tick. Defer
220
+ * the write error by a single turn (next tick runs before the check phase, so a
221
+ * real spawn/exit verdict always wins) and report it only if no verdict arrives.
222
+ * No arbitration timer; concurrent write failures collapse into the client's
223
+ * single first-error terminal state via `fail`'s idempotence.
224
+ */
225
+ failWrite(command, error) {
226
+ if (this.failed)
227
+ return;
228
+ const message = `Pi RPC ${command} write failed: ${error instanceof Error ? error.message : String(error)}`;
229
+ setImmediate(() => {
230
+ if (this.failed)
231
+ return;
232
+ this.fail(new Error(message), true);
233
+ });
234
+ }
213
235
  fail(error, terminate) {
214
236
  if (this.failed)
215
237
  return;
@@ -245,6 +267,9 @@ class PiRpcClient {
245
267
  resolve();
246
268
  };
247
269
  this.process.once("exit", finish);
270
+ // A failed spawn never emits "exit": settle on "close" instead of waiting
271
+ // out the grace timers (or never, when nothing else keeps the loop alive).
272
+ this.process.once("close", finish);
248
273
  this.process.kill("SIGTERM");
249
274
  forceTimer = setTimeout(() => {
250
275
  if (this.exited) {
@@ -44,11 +44,15 @@ export class PiRpcClient {
44
44
  this.stderrBuffer = Buffer.concat([this.stderrBuffer, value.subarray(0, this.maxStderrBytes - this.stderrBuffer.length)]);
45
45
  });
46
46
  process.once("error", (error) => this.fail(new Error(`Pi RPC process failed: ${error.message}`), true));
47
- process.once("exit", (code, signal) => {
47
+ const settle = (code, signal) => {
48
48
  this.exited = true;
49
49
  const stderr = this.stderrBuffer.toString("utf8").trim().slice(0, 2_000);
50
50
  this.fail(new Error(`Pi RPC process exited (${code ?? signal ?? "unknown"})${stderr ? `: ${stderr}` : ""}`), false);
51
- });
51
+ };
52
+ process.once("exit", settle);
53
+ // A spawn that never started emits "error" + "close" and never "exit", so
54
+ // "close" is the only terminal event Node guarantees for failed spawns.
55
+ process.once("close", settle);
52
56
  }
53
57
  subscribe(listener) {
54
58
  this.eventListeners.add(listener);
@@ -86,11 +90,11 @@ export class PiRpcClient {
86
90
  throw new Error("stdin is unavailable");
87
91
  this.process.stdin.write(`${JSON.stringify({ id, type: command, ...fields })}\n`, (error) => {
88
92
  if (error)
89
- this.fail(new Error(`Pi RPC ${command} write failed: ${error.message}`), true);
93
+ this.failWrite(command, error);
90
94
  });
91
95
  }
92
96
  catch (error) {
93
- this.fail(new Error(`Pi RPC ${command} write failed: ${error instanceof Error ? error.message : String(error)}`), true);
97
+ this.failWrite(command, error);
94
98
  }
95
99
  });
96
100
  }
@@ -207,6 +211,24 @@ export class PiRpcClient {
207
211
  protocolFailure(detail) {
208
212
  this.fail(new Error(`Pi RPC protocol error: ${detail}`), true);
209
213
  }
214
+ /**
215
+ * A failed write matters less than why the process is gone: a spawn failure or
216
+ * an early exit explains it, and that verdict is queued on the next tick. Defer
217
+ * the write error by a single turn (next tick runs before the check phase, so a
218
+ * real spawn/exit verdict always wins) and report it only if no verdict arrives.
219
+ * No arbitration timer; concurrent write failures collapse into the client's
220
+ * single first-error terminal state via `fail`'s idempotence.
221
+ */
222
+ failWrite(command, error) {
223
+ if (this.failed)
224
+ return;
225
+ const message = `Pi RPC ${command} write failed: ${error instanceof Error ? error.message : String(error)}`;
226
+ setImmediate(() => {
227
+ if (this.failed)
228
+ return;
229
+ this.fail(new Error(message), true);
230
+ });
231
+ }
210
232
  fail(error, terminate) {
211
233
  if (this.failed)
212
234
  return;
@@ -242,6 +264,9 @@ export class PiRpcClient {
242
264
  resolve();
243
265
  };
244
266
  this.process.once("exit", finish);
267
+ // A failed spawn never emits "exit": settle on "close" instead of waiting
268
+ // out the grace timers (or never, when nothing else keeps the loop alive).
269
+ this.process.once("close", finish);
245
270
  this.process.kill("SIGTERM");
246
271
  forceTimer = setTimeout(() => {
247
272
  if (this.exited) {
@@ -181,12 +181,22 @@ function resolveRuntimeExecutable(command, env = process.env) {
181
181
  }
182
182
  function classifyRuntimePrerequisite(runtime, error, executable) {
183
183
  const reason = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 500);
184
- if (/ENOENT|not found|no such file|spawn .* failed/i.test(reason))
184
+ if (/ENOENT|not found|no such file|spawn .* failed/i.test(reason)) {
185
+ // A resolved executable that still exists on disk is not a missing install:
186
+ // the spawn failure comes from the launch environment (working directory,
187
+ // interpreter, permissions). Keep the raw reason instead of sending the
188
+ // user to reinstall the runtime.
189
+ if (executable && node_fs_1.default.existsSync(executable))
190
+ return {
191
+ runtime, state: "unavailable", executable, reason,
192
+ nextAction: `Verify the ${runtime} launch environment (working directory, interpreter, permissions), then retry.`,
193
+ };
185
194
  return {
186
195
  runtime, state: "missing", ...(executable ? { executable } : {}),
187
196
  reason: `${runtime} is not installed`,
188
197
  nextAction: runtimeInstallNextAction(runtime),
189
198
  };
199
+ }
190
200
  if (/no authenticated available models|login|credential|unauthenticated|unauthorized|oauth/i.test(reason))
191
201
  return {
192
202
  runtime, state: "unauthenticated", ...(executable ? { executable } : {}), reason,
@@ -128,12 +128,22 @@ export function resolveRuntimeExecutable(command, env = process.env) {
128
128
  }
129
129
  export function classifyRuntimePrerequisite(runtime, error, executable) {
130
130
  const reason = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 500);
131
- if (/ENOENT|not found|no such file|spawn .* failed/i.test(reason))
131
+ if (/ENOENT|not found|no such file|spawn .* failed/i.test(reason)) {
132
+ // A resolved executable that still exists on disk is not a missing install:
133
+ // the spawn failure comes from the launch environment (working directory,
134
+ // interpreter, permissions). Keep the raw reason instead of sending the
135
+ // user to reinstall the runtime.
136
+ if (executable && fs.existsSync(executable))
137
+ return {
138
+ runtime, state: "unavailable", executable, reason,
139
+ nextAction: `Verify the ${runtime} launch environment (working directory, interpreter, permissions), then retry.`,
140
+ };
132
141
  return {
133
142
  runtime, state: "missing", ...(executable ? { executable } : {}),
134
143
  reason: `${runtime} is not installed`,
135
144
  nextAction: runtimeInstallNextAction(runtime),
136
145
  };
146
+ }
137
147
  if (/no authenticated available models|login|credential|unauthenticated|unauthorized|oauth/i.test(reason))
138
148
  return {
139
149
  runtime, state: "unauthenticated", ...(executable ? { executable } : {}), reason,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [