@rikcodes/teamclaude 1.1.20-rik.8 → 1.1.20-rik.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.8",
3
+ "version": "1.1.20-rik.9",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/crash-log.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { appendFileSync } from 'node:fs';
2
2
 
3
+ /** Did a write fail because whatever was reading has gone?
4
+ *
5
+ * Node reports this asynchronously when the stream is non-blocking, so it
6
+ * arrives as an event rather than at the call site and no `try` around the
7
+ * write can catch it. */
8
+ function isBrokenPipe(err) {
9
+ return err?.code === 'EPIPE' || err?.code === 'ERR_STREAM_DESTROYED';
10
+ }
11
+
3
12
  /**
4
13
  * Write a fatal error to `path` before the process dies.
5
14
  *
@@ -11,16 +20,39 @@ import { appendFileSync } from 'node:fs';
11
20
  * Handling these events replaces Node's own behaviour, so this must do what
12
21
  * Node would: report and exit non-zero. Continuing after an uncaught exception
13
22
  * would leave the proxy running on unknown state.
23
+ *
24
+ * A broken pipe is the one exception, and it is deliberate. The terminal going
25
+ * away says nothing about the proxy's state — every account, route and inflight
26
+ * request is exactly as it was — so ending the process punishes every routed
27
+ * session for a closed pane. It happened three times in two days here: the TUI
28
+ * writes to a non-blocking stdout, so the failure could not be caught where it
29
+ * was written, and each death also orphaned a sidecar on its port. Recorded
30
+ * once, because a stream dying is worth knowing, then survived. Once is enough:
31
+ * the stream stays dead, and a storm of them would say nothing new.
14
32
  * @param {string} path
15
33
  * @param {{ exit?: (code: number) => void, log?: { write: (s: string) => void } }} [opts]
16
34
  */
17
35
  export function installCrashHandlers(path, { exit = process.exit, log = process.stderr } = {}) {
36
+ let brokenPipeRecorded = false;
37
+ // 0600: a stack can carry request context. A write failure (read-only home,
38
+ // full disk) must not mask the crash itself — stderr still gets the entry.
39
+ const record = (/** @type {string} */ entry) => {
40
+ try { appendFileSync(path, entry, { mode: 0o600 }); } catch { /* report to stderr regardless */ }
41
+ };
18
42
  const report = (/** @type {string} */ kind) => (/** @type {any} */ err) => {
19
43
  const stack = err?.stack || String(err);
20
- const entry = `\n=== ${new Date().toISOString()} ${kind} ===\n${stack}\n`;
21
- // 0600: a stack can carry request context. A write failure (read-only home,
22
- // full disk) must not mask the crash itself — stderr still gets the entry.
23
- try { appendFileSync(path, entry, { mode: 0o600 }); } catch { /* report to stderr regardless */ }
44
+ // A bare "Error: write EPIPE" names neither the stream nor the call. The
45
+ // code and syscall are what turn the next one of these into a diagnosis
46
+ // rather than an afternoon of inference.
47
+ const detail = [err?.code, err?.syscall].filter(Boolean).join(' ');
48
+ const entry = `\n=== ${new Date().toISOString()} ${kind}${detail ? ` (${detail})` : ''} ===\n${stack}\n`;
49
+ if (isBrokenPipe(err)) {
50
+ if (!brokenPipeRecorded) { brokenPipeRecorded = true; record(entry); }
51
+ // Deliberately not written to `log`: that is very likely the stream that
52
+ // just failed, and would fail again.
53
+ return;
54
+ }
55
+ record(entry);
24
56
  log.write(entry);
25
57
  exit(1);
26
58
  };
package/src/tui.js CHANGED
@@ -586,13 +586,16 @@ export class TUI {
586
586
  this._setStdoutBlocking(true);
587
587
  // Blocking again means a failed write throws here instead of arriving as
588
588
  // an event, and a terminal that has already gone will fail. Restoring the
589
- // screen is best-effort: there is nobody left to restore it for. The error
590
- // listener outlives this write, so a late async failure is absorbed too.
589
+ // screen is best-effort: there is nobody left to restore it for.
591
590
  try { process.stdout.write(`${ESC}?25h${ESC}?1049l`); } catch { /* terminal already gone */ }
592
- if (this._stdoutErrorHandler) {
593
- process.stdout.removeListener('error', this._stdoutErrorHandler);
594
- this._stdoutErrorHandler = null;
595
- }
591
+ // The error listener stays. Flipping back to blocking does not make writes
592
+ // ALREADY QUEUED synchronous, so a paint still in flight can fail after
593
+ // this point, and shutdown() runs well past it — stopping the prober, the
594
+ // warmer and the sidecar, then awaiting a state save. An earlier version
595
+ // removed the listener here while claiming it outlived the write; it did
596
+ // not, and the proxy died of an unhandled EPIPE in exactly that window. It
597
+ // only sets a flag, so leaving it attached for the rest of the process
598
+ // costs nothing.
596
599
  try { process.stdin.setRawMode(false); } catch {}
597
600
  process.stdin.pause();
598
601
  }