@sayknow-cli/utils 0.4.7 → 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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Centralized path helpers for sayknow-cli config directories.
3
3
  *
4
- * Uses PI_CONFIG_DIR (default ".skc") for the config root and
5
- * PI_CODING_AGENT_DIR to override the agent directory.
4
+ * Uses SKC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".skc") for the
5
+ * config root and SKC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
6
+ * override the agent directory.
6
7
  *
7
8
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
9
  * variables are set, paths are redirected to XDG-compliant locations under
@@ -44,7 +45,6 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
44
45
  export declare function getProjectDir(): string;
45
46
  /** Set the project directory. */
46
47
  export declare function setProjectDir(dir: string): void;
47
- /** Get the config directory name relative to home (e.g. ".skc" or PI_CONFIG_DIR override). */
48
48
  export declare function getConfigDirName(): string;
49
49
  /** Get the config agent directory name relative to home (e.g. ".skc/agent" or PI_CONFIG_DIR + "/agent"). */
50
50
  export declare function getConfigAgentDirName(): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
3
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
4
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
5
+ */
6
+ export declare function isValidEnvName(name: string): boolean;
7
+ /**
8
+ * Parses simple POSIX shell environment assignments from files such as
9
+ * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
10
+ * `KEY=value`, including single/double quoted literal values. Dynamic shell
11
+ * expressions are intentionally ignored because evaluating startup files would
12
+ * run arbitrary code during CLI startup.
13
+ */
14
+ export declare function parseShellEnvFile(filePath: string): Record<string, string>;
15
+ /**
16
+ * Parses a .env file synchronously and extracts key-value string pairs.
17
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
18
+ * Allows values to be quoted with single or double quotes.
19
+ * Returns an object of key-value pairs.
20
+ */
21
+ export declare function parseEnvFile(filePath: string): Record<string, string>;
@@ -1,25 +1,5 @@
1
1
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
2
- /**
3
- * Strict shell-identifier shape. Used for dotenv keys we accept into
4
- * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
5
- * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
6
- */
7
- export declare function isValidEnvName(name: string): boolean;
8
- /**
9
- * Parses simple POSIX shell environment assignments from files such as
10
- * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
11
- * `KEY=value`, including single/double quoted literal values. Dynamic shell
12
- * expressions are intentionally ignored because evaluating startup files would
13
- * run arbitrary code during CLI startup.
14
- */
15
- export declare function parseShellEnvFile(filePath: string): Record<string, string>;
16
- /**
17
- * Parses a .env file synchronously and extracts key-value string pairs.
18
- * Ignores lines that are empty or start with '#'. Trims whitespace.
19
- * Allows values to be quoted with single or double quotes.
20
- * Returns an object of key-value pairs.
21
- */
22
- export declare function parseEnvFile(filePath: string): Record<string, string>;
2
+ export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
23
3
  export declare function $inheritedEnv(name: string): string | undefined;
24
4
  /**
25
5
  * Intentional re-export of Bun.env.
@@ -66,3 +46,7 @@ export declare function isBunTestRuntime(): boolean;
66
46
  */
67
47
  export declare function isCompiledBinary(): boolean;
68
48
  export declare function $flag(name: string, def?: boolean): boolean;
49
+ /** Resolve the first flag among keys that has a set value (SKC-first, PI fallback). Matches $flag semantics per key. */
50
+ export declare function $pickflag(...keys: string[]): boolean;
51
+ /** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
52
+ export declare function $pickenvpos(keys: string[], defaultValue: number): number;
@@ -8,6 +8,31 @@ export declare enum Reason {
8
8
  UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
9
9
  MANUAL = "manual"
10
10
  }
11
+ /** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
12
+ export declare const CRASH_LOG_MAX_BYTES: number;
13
+ /**
14
+ * Per-record budget so a single oversized error body cannot bypass the file
15
+ * cap: every persisted record is truncated to this many bytes (UTF-8 safe,
16
+ * with a marker) before the append/reset decision.
17
+ */
18
+ export declare const CRASH_RECORD_MAX_BYTES: number;
19
+ /**
20
+ * Append a fatal-crash record to the dedicated, rotation-immune crash log
21
+ * (`~/.skc/agent/skc-crash.log`).
22
+ *
23
+ * The daily logger file is gzip-archived at date rollover by every skc process
24
+ * independently; that shared-archive race can truncate a day's log to an empty
25
+ * `.gz`, destroying the `logger.error` crash record written here. This
26
+ * append-only file is never rotated, so a crash stays diagnosable regardless.
27
+ *
28
+ * Fully defensive: it never throws (a failing crash writer must not mask the
29
+ * original fatal) and uses synchronous IO so the record lands before
30
+ * `process.exit`. Returns the path written, or `undefined` on failure.
31
+ */
32
+ export declare function recordFatalCrash(label: string, reason: unknown, options?: {
33
+ path?: string;
34
+ now?: Date;
35
+ }): string | undefined;
11
36
  /**
12
37
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
13
38
  *
@@ -28,6 +28,12 @@ export declare function resolveBasicShell(): string | undefined;
28
28
  * 4. Fallback: sh
29
29
  */
30
30
  export declare function getShellConfig(customShellPath?: string): ShellConfig;
31
+ /**
32
+ * Clear the memoized shell configuration so the next {@link getShellConfig}
33
+ * call re-resolves the shell and re-reads the environment (shell selection and
34
+ * the bash CI/login flags). Primarily for tests that vary those inputs.
35
+ */
36
+ export declare function resetShellConfigCache(): void;
31
37
  /**
32
38
  * Check if a process is running.
33
39
  */
@@ -17,7 +17,11 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
17
17
  * ```
18
18
  */
19
19
  export type SseEventObserver = (event: ServerSentEvent) => void;
20
- export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
20
+ export interface SseReadOptions {
21
+ maxEventBytes?: number;
22
+ maxTotalBytes?: number;
23
+ }
24
+ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver, options?: SseReadOptions): AsyncGenerator<T>;
21
25
  /**
22
26
  * A single Server-Sent Event dispatched on a blank-line boundary.
23
27
  *
@@ -53,7 +57,7 @@ export interface ServerSentEvent {
53
57
  * }
54
58
  * ```
55
59
  */
56
- export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<ServerSentEvent>;
60
+ export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: SseReadOptions): AsyncGenerator<ServerSentEvent>;
57
61
  /**
58
62
  * Parse a complete JSONL string, skipping malformed lines instead of throwing.
59
63
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/utils",
4
- "version": "0.4.7",
4
+ "version": "0.5.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@sayknow-cli/natives": "0.4.7",
34
+ "@sayknow-cli/natives": "0.5.0",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/cli.ts CHANGED
@@ -211,8 +211,11 @@ export abstract class Command {
211
211
  if (raw === undefined || typeof raw === "boolean") {
212
212
  flags[name] = desc.default ?? undefined;
213
213
  } else {
214
- const n = Number.parseInt(raw as string, 10);
215
- if (Number.isNaN(n)) {
214
+ if (typeof raw !== "string" || !/^-?\d+$/.test(raw)) {
215
+ throw new CliParseError(`Expected integer for --${name}, got "${String(raw)}"`);
216
+ }
217
+ const n = Number(raw);
218
+ if (!Number.isSafeInteger(n)) {
216
219
  throw new CliParseError(`Expected integer for --${name}, got "${raw}"`);
217
220
  }
218
221
  flags[name] = n;
@@ -267,6 +270,12 @@ export abstract class Command {
267
270
  }
268
271
  }
269
272
 
273
+ if (strict && posIdx < positionals.length) {
274
+ const unexpected = positionals.slice(posIdx);
275
+ const rendered = unexpected.map(value => JSON.stringify(value)).join(", ");
276
+ throw new CliParseError(`Unexpected argument${unexpected.length === 1 ? "" : "s"}: ${rendered}`);
277
+ }
278
+
270
279
  return { flags, args, argv: positionals } as never;
271
280
  }
272
281
  }
package/src/dirs.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Centralized path helpers for sayknow-cli config directories.
3
3
  *
4
- * Uses PI_CONFIG_DIR (default ".skc") for the config root and
5
- * PI_CODING_AGENT_DIR to override the agent directory.
4
+ * Uses SKC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".skc") for the
5
+ * config root and SKC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
6
+ * override the agent directory.
6
7
  *
7
8
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
9
  * variables are set, paths are redirected to XDG-compliant locations under
@@ -15,6 +16,7 @@ import * as fs from "node:fs";
15
16
  import * as os from "node:os";
16
17
  import * as path from "node:path";
17
18
  import { engines, version } from "../package.json" with { type: "json" };
19
+ import { parseEnvFile } from "./env-file";
18
20
 
19
21
  /** App name (e.g. "skc") */
20
22
  export const APP_NAME: string = "skc";
@@ -147,9 +149,57 @@ export function setProjectDir(dir: string): void {
147
149
  process.chdir(projectDir);
148
150
  }
149
151
 
152
+ /**
153
+ * Reject a configured config-directory name that would escape the home-relative
154
+ * root it is documented to stay under.
155
+ *
156
+ * The configured value names a directory beneath `<home>` — the discovery docs
157
+ * state that "even an absolute-looking configured name is joined beneath
158
+ * `<home>`", which `path.join` delivers for a leading separator but not for
159
+ * `..` segments. Consumers join this name with `<home>` (and with project
160
+ * ancestors) to locate user-level `mcp.json`, `SYSTEM.md`, skills, agents and
161
+ * installed plugins, so a `..` segment would point that discovery at a
162
+ * directory outside the config root entirely. Fall back to the default name
163
+ * instead of honoring an escaping value.
164
+ */
165
+ function sanitizeConfigDirName(value: string | undefined): string | undefined {
166
+ const trimmed = value?.trim();
167
+ if (!trimmed) return undefined;
168
+ if (path.normalize(trimmed).split(/[\\/]/).includes("..")) return undefined;
169
+ return trimmed;
170
+ }
171
+
150
172
  /** Get the config directory name relative to home (e.g. ".skc" or PI_CONFIG_DIR override). */
173
+ /**
174
+ * Config-directory name, rejected when it comes from the caller's project `.env`.
175
+ *
176
+ * The name is joined with the home directory to build the config root, and that
177
+ * root plus the agent directory beneath it supply two of the `.env` files
178
+ * `$credentialEnv` treats as trusted. Bun loads `cwd/.env` into `process.env`
179
+ * before any module runs, so a repository could otherwise point the config root
180
+ * at a directory it ships and have its own `.env` treated as trusted —
181
+ * recovering every endpoint and credential redirect the boundary rejects.
182
+ *
183
+ * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
184
+ * it applies the same conservative ambiguity rule directly, matching how
185
+ * `SKC_CODING_AGENT_DIR` is treated.
186
+ */
187
+ function trustedConfigDirName(name: "SKC_CONFIG_DIR" | "PI_CONFIG_DIR"): string | undefined {
188
+ const value = process.env[name];
189
+ if (!value) return undefined;
190
+ if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
191
+ return value;
192
+ }
193
+
151
194
  export function getConfigDirName(): string {
152
- return process.env.SKC_CONFIG_DIR ?? process.env.PI_CONFIG_DIR ?? CONFIG_DIR_NAME;
195
+ // Both guards apply: the value must come from a trusted source (not the
196
+ // caller's project `.env`), and it must still be a single name that stays
197
+ // beneath home once joined.
198
+ return (
199
+ sanitizeConfigDirName(trustedConfigDirName("SKC_CONFIG_DIR")) ??
200
+ sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
201
+ CONFIG_DIR_NAME
202
+ );
153
203
  }
154
204
 
155
205
  /** Get the config agent directory name relative to home (e.g. ".skc/agent" or PI_CONFIG_DIR + "/agent"). */
@@ -248,7 +298,43 @@ class DirResolver {
248
298
  }
249
299
  }
250
300
 
251
- let dirs = new DirResolver(process.env.SKC_CODING_AGENT_DIR);
301
+ /**
302
+ * Agent-directory override, rejected when it comes from the caller's project
303
+ * `.env`.
304
+ *
305
+ * This directory selects the agent's own `.env`, which is one of the trusted
306
+ * sources `$credentialEnv` consults. Bun loads `cwd/.env` into `process.env`
307
+ * before any module runs, so a repository could otherwise point this at a
308
+ * directory it ships and have its own `.env` treated as trusted — recovering
309
+ * every redirect the credential boundary is meant to reject.
310
+ *
311
+ * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
312
+ * it applies the same conservative ambiguity rule directly: a value that matches
313
+ * what the project `.env` sets is not honoured. An operator whose environment
314
+ * happens to carry the identical value loses the override, which is the same
315
+ * trade-off `resolveLiveCredentialEnvValue` already makes.
316
+ */
317
+ function trustedAgentDirOverrideFor(name: "SKC_CODING_AGENT_DIR" | "PI_CODING_AGENT_DIR"): string | undefined {
318
+ const value = process.env[name];
319
+ if (!value) return undefined;
320
+ if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
321
+ return value;
322
+ }
323
+
324
+ /**
325
+ * Both spellings are honoured, mirroring `getConfigDirName`.
326
+ *
327
+ * `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
328
+ * and parts of the product already resolve it (`gc-runtime.ts:370`,
329
+ * `deep-interview-runtime.ts:384`). Reading only the `SKC_` spelling here split
330
+ * the agent directory in two: `skc gc` operated on the aliased directory while
331
+ * everything reaching `getAgentDir()` stayed on the default.
332
+ */
333
+ function trustedAgentDirOverride(): string | undefined {
334
+ return trustedAgentDirOverrideFor("SKC_CODING_AGENT_DIR") ?? trustedAgentDirOverrideFor("PI_CODING_AGENT_DIR");
335
+ }
336
+
337
+ let dirs = new DirResolver(trustedAgentDirOverride());
252
338
 
253
339
  // Anchor home for the resolver. Captured at module load to stay stable across
254
340
  // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Environment-file parsing primitives.
3
+ *
4
+ * Kept in a leaf module so both `env.ts` and `dirs.ts` can use them. `env.ts`
5
+ * imports `dirs.ts`, so anything `dirs.ts` needs from the env layer has to live
6
+ * below both of them.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import { isSafeEnvValue } from "./spawn-env";
10
+
11
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
+
13
+ /**
14
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
15
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
16
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
17
+ */
18
+ export function isValidEnvName(name: string): boolean {
19
+ return ENV_NAME_RE.test(name);
20
+ }
21
+
22
+ function stripInlineShellComment(value: string): string {
23
+ let quote: '"' | "'" | undefined;
24
+ for (let i = 0; i < value.length; i++) {
25
+ const char = value[i];
26
+ if (char === "\\") {
27
+ i++;
28
+ continue;
29
+ }
30
+ if ((char === '"' || char === "'") && (!quote || quote === char)) {
31
+ quote = quote ? undefined : char;
32
+ continue;
33
+ }
34
+ if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
35
+ return value.slice(0, i).trimEnd();
36
+ }
37
+ }
38
+ return value.trimEnd();
39
+ }
40
+
41
+ /**
42
+ * Parses simple POSIX shell environment assignments from files such as
43
+ * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
44
+ * `KEY=value`, including single/double quoted literal values. Dynamic shell
45
+ * expressions are intentionally ignored because evaluating startup files would
46
+ * run arbitrary code during CLI startup.
47
+ */
48
+ export function parseShellEnvFile(filePath: string): Record<string, string> {
49
+ const result: Record<string, string> = {};
50
+ try {
51
+ const content = fs.readFileSync(filePath, "utf-8");
52
+ for (const line of content.split("\n")) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith("#")) continue;
55
+
56
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
57
+ if (!match) continue;
58
+
59
+ const key = match[1];
60
+ if (!isValidEnvName(key)) continue;
61
+
62
+ let value = stripInlineShellComment(match[2] ?? "").trim();
63
+ if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
64
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
65
+ value = value.slice(1, -1);
66
+ }
67
+ if (!isSafeEnvValue(value)) continue;
68
+ if (/[$`]/.test(value)) continue;
69
+
70
+ result[key] = value;
71
+ }
72
+ } catch {
73
+ // File doesn't exist or can't be read - return empty result
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Parses a .env file synchronously and extracts key-value string pairs.
81
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
82
+ * Allows values to be quoted with single or double quotes.
83
+ * Returns an object of key-value pairs.
84
+ */
85
+ export function parseEnvFile(filePath: string): Record<string, string> {
86
+ const result: Record<string, string> = {};
87
+ try {
88
+ const content = fs.readFileSync(filePath, "utf-8");
89
+ for (const line of content.split("\n")) {
90
+ const trimmed = line.trim();
91
+ // Skip comments and blank lines
92
+ if (!trimmed || trimmed.startsWith("#")) continue;
93
+
94
+ const eqIndex = trimmed.indexOf("=");
95
+ if (eqIndex === -1) continue;
96
+
97
+ const key = trimmed.slice(0, eqIndex).trim();
98
+ if (!isValidEnvName(key)) continue;
99
+
100
+ let value = trimmed.slice(eqIndex + 1).trim();
101
+
102
+ // Remove surrounding quotes (" or ')
103
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
104
+ value = value.slice(1, -1);
105
+ }
106
+ if (!isSafeEnvValue(value)) continue;
107
+
108
+ result[key] = value;
109
+ }
110
+ } catch {
111
+ // File doesn't exist or can't be read - return empty result
112
+ }
113
+
114
+ return result;
115
+ }
package/src/env.ts CHANGED
@@ -1,4 +1,3 @@
1
- import * as fs from "node:fs";
2
1
  import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { getAgentDir, getConfigRootDir } from "./dirs";
@@ -6,111 +5,10 @@ import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
6
5
 
7
6
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
8
7
 
9
- const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ import { parseEnvFile, parseShellEnvFile } from "./env-file";
10
9
 
11
- /**
12
- * Strict shell-identifier shape. Used for dotenv keys we accept into
13
- * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
14
- * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
15
- */
16
- export function isValidEnvName(name: string): boolean {
17
- return ENV_NAME_RE.test(name);
18
- }
19
-
20
- function stripInlineShellComment(value: string): string {
21
- let quote: '"' | "'" | undefined;
22
- for (let i = 0; i < value.length; i++) {
23
- const char = value[i];
24
- if (char === "\\") {
25
- i++;
26
- continue;
27
- }
28
- if ((char === '"' || char === "'") && (!quote || quote === char)) {
29
- quote = quote ? undefined : char;
30
- continue;
31
- }
32
- if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
33
- return value.slice(0, i).trimEnd();
34
- }
35
- }
36
- return value.trimEnd();
37
- }
38
-
39
- /**
40
- * Parses simple POSIX shell environment assignments from files such as
41
- * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
42
- * `KEY=value`, including single/double quoted literal values. Dynamic shell
43
- * expressions are intentionally ignored because evaluating startup files would
44
- * run arbitrary code during CLI startup.
45
- */
46
- export function parseShellEnvFile(filePath: string): Record<string, string> {
47
- const result: Record<string, string> = {};
48
- try {
49
- const content = fs.readFileSync(filePath, "utf-8");
50
- for (const line of content.split("\n")) {
51
- const trimmed = line.trim();
52
- if (!trimmed || trimmed.startsWith("#")) continue;
53
-
54
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
55
- if (!match) continue;
56
-
57
- const key = match[1];
58
- if (!isValidEnvName(key)) continue;
59
-
60
- let value = stripInlineShellComment(match[2] ?? "").trim();
61
- if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
62
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
63
- value = value.slice(1, -1);
64
- }
65
- if (!isSafeEnvValue(value)) continue;
66
- if (/[$`]/.test(value)) continue;
67
-
68
- result[key] = value;
69
- }
70
- } catch {
71
- // File doesn't exist or can't be read - return empty result
72
- }
73
-
74
- return result;
75
- }
76
-
77
- /**
78
- * Parses a .env file synchronously and extracts key-value string pairs.
79
- * Ignores lines that are empty or start with '#'. Trims whitespace.
80
- * Allows values to be quoted with single or double quotes.
81
- * Returns an object of key-value pairs.
82
- */
83
- export function parseEnvFile(filePath: string): Record<string, string> {
84
- const result: Record<string, string> = {};
85
- try {
86
- const content = fs.readFileSync(filePath, "utf-8");
87
- for (const line of content.split("\n")) {
88
- const trimmed = line.trim();
89
- // Skip comments and blank lines
90
- if (!trimmed || trimmed.startsWith("#")) continue;
91
-
92
- const eqIndex = trimmed.indexOf("=");
93
- if (eqIndex === -1) continue;
94
-
95
- const key = trimmed.slice(0, eqIndex).trim();
96
- if (!isValidEnvName(key)) continue;
97
-
98
- let value = trimmed.slice(eqIndex + 1).trim();
99
-
100
- // Remove surrounding quotes (" or ')
101
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
102
- value = value.slice(1, -1);
103
- }
104
- if (!isSafeEnvValue(value)) continue;
105
-
106
- result[key] = value;
107
- }
108
- } catch {
109
- // File doesn't exist or can't be read - return empty result
110
- }
111
-
112
- return result;
113
- }
10
+ // Re-exported so the public surface of this module is unchanged.
11
+ export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
114
12
 
115
13
  function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
116
14
  if (!isSafeEnvName(name)) return undefined;
@@ -279,3 +177,23 @@ export function $flag(name: string, def: boolean = false): boolean {
279
177
  // would silently read as false while only `FOO=TRUE`/`FOO=1` worked.
280
178
  return TRUTHY[value.toUpperCase()] === true;
281
179
  }
180
+
181
+ /** Resolve the first flag among keys that has a set value (SKC-first, PI fallback). Matches $flag semantics per key. */
182
+ export function $pickflag(...keys: string[]): boolean {
183
+ for (const key of keys) {
184
+ const value = $env[key]?.trim();
185
+ if (value) return TRUTHY[value.toUpperCase()] === true;
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
191
+ export function $pickenvpos(keys: string[], defaultValue: number): number {
192
+ for (const key of keys) {
193
+ const raw = $env[key]?.trim();
194
+ if (!raw) continue;
195
+ const parsed = Number.parseInt(raw, 10);
196
+ if (!Number.isNaN(parsed) && parsed > 0) return parsed;
197
+ }
198
+ return defaultValue;
199
+ }
package/src/format.ts CHANGED
@@ -9,7 +9,9 @@ const DAY = 24 * HOUR;
9
9
  */
10
10
  export function formatDuration(ms: number): string {
11
11
  if (ms < SEC) return `${ms}ms`;
12
- if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
12
+ // Truncate below 60.0s instead of rounding up into the next unit (the minute
13
+ // branch below), mirroring roundBelow/formatByteUnit: e.g. 59_999ms -> "59.9s".
14
+ if (ms < MIN) return `${(roundBelow(ms / 100, 600) / 10).toFixed(1)}s`;
13
15
  if (ms < HOUR) {
14
16
  const mins = Math.floor(ms / MIN);
15
17
  const secs = Math.floor((ms % MIN) / SEC);
@@ -59,13 +61,21 @@ export function formatBytes(bytes: number): string {
59
61
  if (bytes < 1024) return `${bytes}B`;
60
62
  if (bytes < 1024 * 1024) return `${formatByteUnit(bytes, 1024)}KB`;
61
63
  if (bytes < 1024 * 1024 * 1024) return `${formatByteUnit(bytes, 1024 * 1024)}MB`;
62
- return `${formatByteUnit(bytes, 1024 * 1024 * 1024)}GB`;
64
+ // The GB branch is terminal (no larger unit), so it must not clamp below the
65
+ // next-unit boundary the way KB/MB do; clamping here reports every value >=
66
+ // 1 TiB as "1023.9GB" (e.g. 2 TiB -> "1023.9GB" instead of "2048.0GB").
67
+ return `${formatByteUnit(bytes, 1024 * 1024 * 1024, false)}GB`;
63
68
  }
64
69
 
65
- /** Format bytes to 1 decimal without rounding up into the next byte unit. */
66
- function formatByteUnit(bytes: number, unit: number): string {
67
- const tenths = Math.min(Math.round((bytes / unit) * 10), 1024 * 10 - 1);
68
- return (tenths / 10).toFixed(1);
70
+ /**
71
+ * Format bytes to 1 decimal. Intermediate units (KB/MB) clamp just below the
72
+ * next-unit boundary so a value like 1023.95 KB never rounds up to "1024.0KB";
73
+ * the terminal GB unit passes `clampToNextUnit: false` because it has no next
74
+ * unit to protect against.
75
+ */
76
+ function formatByteUnit(bytes: number, unit: number, clampToNextUnit = true): string {
77
+ const tenths = Math.round((bytes / unit) * 10);
78
+ return ((clampToNextUnit ? Math.min(tenths, 1024 * 10 - 1) : tenths) / 10).toFixed(1);
69
79
  }
70
80
 
71
81
  /**
@@ -124,18 +124,30 @@ export function parseFrontmatter(
124
124
  const loc = location ?? source;
125
125
  const frontmatter: Record<string, unknown> = { ...fallback };
126
126
 
127
- const normalized = normalize ? stripHtmlComments(content.replace(/\r\n?/g, "\n")) : content;
128
- if (!normalized.startsWith("---")) {
127
+ // Normalize away a leading UTF-8 BOM and CRLF line endings before matching so
128
+ // a BOM-prefixed but otherwise valid document is still recognized as
129
+ // frontmatter (BOM-prefixed Markdown is common on Windows-authored files).
130
+ const normalized = normalize ? stripHtmlComments(content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")) : content;
131
+ // A frontmatter block opens with a line that is exactly `---` (trailing
132
+ // spaces/tabs allowed). A bare `----` banner or a `--- text` heading is not
133
+ // an opener, so a document without frontmatter keeps its body intact rather
134
+ // than having content silently consumed by the fixed-offset slicing below.
135
+ const open = normalized.match(/^---[ \t]*(?:\n|$)/);
136
+ if (!open) {
129
137
  return { frontmatter, body: normalized };
130
138
  }
131
139
 
132
- const endIndex = normalized.indexOf("\n---", 3);
133
- if (endIndex === -1) {
140
+ // The block closes at the next line that is exactly `---`. Searching from the
141
+ // end of the opening `---` (offset 3, not the whole opener) keeps an empty
142
+ // block (`---\n---`) matching as empty frontmatter.
143
+ const afterOpen = normalized.slice(3);
144
+ const close = afterOpen.match(/\n---[ \t]*(?:\n|$)/);
145
+ if (!close || close.index === undefined) {
134
146
  return { frontmatter, body: normalized };
135
147
  }
136
148
 
137
- const metadata = normalized.slice(4, endIndex);
138
- const body = normalized.slice(endIndex + 4).trim();
149
+ const metadata = afterOpen.slice(open[0].length - 3, close.index);
150
+ const body = afterOpen.slice(close.index + close[0].length).trim();
139
151
 
140
152
  try {
141
153
  // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
package/src/logger.ts CHANGED
@@ -410,7 +410,10 @@ function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]):
410
410
  }
411
411
  const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
412
412
  lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
413
- const showAll = process.env.PI_TIMING === "full";
413
+ // Resolve SKC-first with PI fallback inline (no ./env import) so this
414
+ // foundational logger stays off the env module's dependency graph, which the
415
+ // tab-worker native-free runtime contract (issue-2598-repro) walks.
416
+ const showAll = (process.env.SKC_TIMING?.trim() || process.env.PI_TIMING?.trim()) === "full";
414
417
  const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
415
418
  const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
416
419
  for (const span of visible) {
package/src/postmortem.ts CHANGED
@@ -5,9 +5,12 @@
5
5
  * in response to process exit, signals, or fatal exceptions. It is intended to
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
+ import * as fs from "node:fs";
8
9
  import inspector from "node:inspector";
10
+ import * as path from "node:path";
9
11
  import { isMainThread } from "node:worker_threads";
10
12
  import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
13
+ import { getCrashLogPath } from "./dirs";
11
14
  import * as logger from "./logger";
12
15
  import { safeStderrWrite } from "./safe-stderr";
13
16
 
@@ -204,6 +207,104 @@ function formatFatalError(label: string, err: Error): string {
204
207
  const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
205
208
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
206
209
  }
210
+ /** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
211
+ export const CRASH_LOG_MAX_BYTES = 512 * 1024;
212
+ /**
213
+ * Per-record budget so a single oversized error body cannot bypass the file
214
+ * cap: every persisted record is truncated to this many bytes (UTF-8 safe,
215
+ * with a marker) before the append/reset decision.
216
+ */
217
+ export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
218
+ const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
219
+
220
+ /**
221
+ * Best-effort scrub of credential material from a crash record before it is
222
+ * persisted indefinitely. Covers bearer/basic-style headers, key=value or
223
+ * JSON key forms of common credential names, and well-known vendor token
224
+ * shapes. Normal messages and stack frames are untouched; matches are
225
+ * replaced in place so surrounding diagnostic context survives.
226
+ */
227
+ function redactCrashSecrets(text: string): string {
228
+ let redacted = text;
229
+ redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
230
+ redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
231
+ redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
232
+ redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
233
+ redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
234
+ redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
235
+ redacted = redacted.replace(
236
+ /(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
237
+ "$1«redacted»",
238
+ );
239
+ return redacted;
240
+ }
241
+
242
+ /**
243
+ * Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
244
+ * sequence. Keeps the header (timestamp/label/message) at the front, where
245
+ * the diagnostic value is highest.
246
+ */
247
+ function boundCrashRecord(report: string): string {
248
+ if (Buffer.byteLength(report, "utf8") <= CRASH_RECORD_MAX_BYTES) return report;
249
+ const bytes = Buffer.from(report, "utf8");
250
+ const budget = CRASH_RECORD_MAX_BYTES - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
251
+ let end = budget;
252
+ // Drop trailing continuation bytes of a truncated multi-byte sequence.
253
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
254
+ // Drop the now-incomplete lead byte, if any.
255
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
256
+ return bytes.subarray(0, end).toString("utf8") + CRASH_RECORD_TRUNCATION_MARKER;
257
+ }
258
+
259
+ /**
260
+ * Append a fatal-crash record to the dedicated, rotation-immune crash log
261
+ * (`~/.skc/agent/skc-crash.log`).
262
+ *
263
+ * The daily logger file is gzip-archived at date rollover by every skc process
264
+ * independently; that shared-archive race can truncate a day's log to an empty
265
+ * `.gz`, destroying the `logger.error` crash record written here. This
266
+ * append-only file is never rotated, so a crash stays diagnosable regardless.
267
+ *
268
+ * Fully defensive: it never throws (a failing crash writer must not mask the
269
+ * original fatal) and uses synchronous IO so the record lands before
270
+ * `process.exit`. Returns the path written, or `undefined` on failure.
271
+ */
272
+ export function recordFatalCrash(
273
+ label: string,
274
+ reason: unknown,
275
+ options: { path?: string; now?: Date } = {},
276
+ ): string | undefined {
277
+ try {
278
+ const err = errorForDiagnostic(reason);
279
+ const target = options.path ?? getCrashLogPath();
280
+ const now = options.now ?? new Date();
281
+ const report = boundCrashRecord(
282
+ `${now.toISOString()} pid=${process.pid} [${label}] ` +
283
+ `${err.name || "Error"}: ${redactCrashSecrets(err.message || "(no message)")}\n` +
284
+ `${redactCrashSecrets(err.stack ?? "")}\n\n`,
285
+ );
286
+ fs.mkdirSync(path.dirname(target), { recursive: true });
287
+ let existingSize = 0;
288
+ try {
289
+ existingSize = fs.statSync(target).size;
290
+ } catch {}
291
+ // Reset (rather than append) when the file would exceed the cap so the
292
+ // newest crash is always retained without unbounded growth. Every record
293
+ // is individually bounded above, so no single crash can bypass the cap.
294
+ if (existingSize + Buffer.byteLength(report, "utf8") > CRASH_LOG_MAX_BYTES) {
295
+ fs.writeFileSync(target, report, { mode: 0o600 });
296
+ } else {
297
+ fs.appendFileSync(target, report, { mode: 0o600 });
298
+ }
299
+ // A pre-existing file may carry looser permissions; enforce owner-only.
300
+ try {
301
+ fs.chmodSync(target, 0o600);
302
+ } catch {}
303
+ return target;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
207
308
 
208
309
  /**
209
310
  * True for terminal/pipe/disk write failures (EIO, EPIPE, EBADF, …) on the write side.
@@ -252,7 +353,12 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
252
353
  ordinaryFatalStarted = true;
253
354
  process.exitCode = 1;
254
355
  const err = errorForDiagnostic(reason);
356
+ // Persist first: the rotation-immune record must land before any
357
+ // best-effort stderr output, so a slow or failing stderr cannot cost the
358
+ // crash record. Cleanup (which may itself hang or fail) runs afterwards.
359
+ const crashLogPath = recordFatalCrash(label, err);
255
360
  safeStderrWrite(formatFatalError(label, err));
361
+ if (crashLogPath) safeStderrWrite(`[${label}] crash recorded at ${crashLogPath}\n`);
256
362
  if (!quietShutdownStarted) {
257
363
  logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
258
364
  err,
package/src/procmgr.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Process, ProcessStatus } from "@sayknow-cli/natives";
4
4
  import type { Subprocess } from "bun";
5
- import { $env, filterProcessEnv } from "./env";
5
+ import { $pickCredentialEnv, $pickflag, filterProcessEnv } from "./env";
6
6
  import { $which } from "./which";
7
7
 
8
8
  export interface ShellConfig {
@@ -41,9 +41,13 @@ function isExecutable(path: string): boolean {
41
41
 
42
42
  /**
43
43
  * Build the spawn environment (cached).
44
+ *
45
+ * `CI=true` is injected unless the documented `SKC_BASH_NO_CI` (or its legacy
46
+ * `PI_BASH_NO_CI` / `CLAUDE_BASH_NO_CI` aliases) is set to a canonical truthy
47
+ * flag value.
44
48
  */
45
49
  function buildSpawnEnv(shell: string): Record<string, string> {
46
- const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
50
+ const noCI = $pickflag("SKC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
47
51
  const inherited = filterProcessEnv(Bun.env);
48
52
  delete inherited.SKC_SESSION_FILE;
49
53
  delete inherited.SKC_MANAGED_OWNER_TRANSCRIPT_PATH;
@@ -60,18 +64,30 @@ function buildSpawnEnv(shell: string): Record<string, string> {
60
64
 
61
65
  /**
62
66
  * Get shell args, optionally including login shell flag.
63
- * Supports PI_BASH_NO_LOGIN and ANTHROPIC_MODEL_BASH_NO_LOGIN to skip -l.
67
+ *
68
+ * Honors the documented `SKC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
69
+ * `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
70
+ * canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
71
+ * explicit `SKC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
72
+ * is set to a truthy value.
64
73
  */
65
74
  function getShellArgs(): string[] {
66
- const noLogin = $env.PI_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
75
+ const noLogin = $pickflag("SKC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
67
76
  return noLogin ? ["-c"] : ["-l", "-c"];
68
77
  }
69
78
 
70
79
  /**
71
80
  * Get shell prefix for wrapping commands (profilers, strace, etc.).
81
+ *
82
+ * Resolved from trusted sources only. The prefix is interpolated ahead of every
83
+ * bash command (`${prefix} ${command}`) and executed through the shell, so it is
84
+ * an arbitrary-command-execution surface. `$env` merges the caller's
85
+ * `cwd/.env`, which means repository content could otherwise set it; resolution
86
+ * therefore goes through the non-project resolver (launching shell plus
87
+ * SKC/user-owned `.env` files), matching how provider credentials are resolved.
72
88
  */
73
89
  function getShellPrefix(): string | undefined {
74
- return $env.PI_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX;
90
+ return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
75
91
  }
76
92
 
77
93
  /**
@@ -187,6 +203,15 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
187
203
  return cachedShellConfig;
188
204
  }
189
205
 
206
+ /**
207
+ * Clear the memoized shell configuration so the next {@link getShellConfig}
208
+ * call re-resolves the shell and re-reads the environment (shell selection and
209
+ * the bash CI/login flags). Primarily for tests that vary those inputs.
210
+ */
211
+ export function resetShellConfigCache(): void {
212
+ cachedShellConfig = null;
213
+ }
214
+
190
215
  /**
191
216
  * Check if a process is running.
192
217
  */
package/src/stream.ts CHANGED
@@ -125,16 +125,22 @@ class ConcatSink {
125
125
  this.#length = 0;
126
126
  }
127
127
 
128
- *appendAndFlushLines(chunk: Uint8Array) {
128
+ *appendAndFlushLines(chunk: Uint8Array, maxLineBytes?: () => number) {
129
129
  let pos = 0;
130
130
  while (pos < chunk.length) {
131
131
  const nl = chunk.indexOf(LF, pos);
132
132
  if (nl === -1) {
133
+ if (maxLineBytes && this.#length + chunk.length - pos > maxLineBytes()) {
134
+ throw new Error("SSE event exceeds size limit");
135
+ }
133
136
  this.append(chunk.subarray(pos));
134
137
  return;
135
138
  }
136
139
  const suffix = chunk.subarray(pos, nl);
137
140
  pos = nl + 1;
141
+ if (maxLineBytes && this.#length + suffix.length + 1 > maxLineBytes()) {
142
+ throw new Error("SSE event exceeds size limit");
143
+ }
138
144
  if (this.isEmpty) {
139
145
  yield suffix;
140
146
  } else {
@@ -201,6 +207,11 @@ class ConcatSink {
201
207
  */
202
208
  export type SseEventObserver = (event: ServerSentEvent) => void;
203
209
 
210
+ export interface SseReadOptions {
211
+ maxEventBytes?: number;
212
+ maxTotalBytes?: number;
213
+ }
214
+
204
215
  function notifySseEventObserver(observer: SseEventObserver | undefined, event: ServerSentEvent): void {
205
216
  if (!observer) return;
206
217
  try {
@@ -214,8 +225,9 @@ export async function* readSseJson<T>(
214
225
  stream: ReadableStream<Uint8Array>,
215
226
  signal?: AbortSignal,
216
227
  onEvent?: SseEventObserver,
228
+ options?: SseReadOptions,
217
229
  ): AsyncGenerator<T> {
218
- for await (const sse of readSseEvents(stream, signal)) {
230
+ for await (const sse of readSseEvents(stream, signal, options)) {
219
231
  notifySseEventObserver(onEvent, sse);
220
232
  const data = sse.data;
221
233
  if (data === "" || data === "[DONE]") {
@@ -336,14 +348,26 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
336
348
  export async function* readSseEvents(
337
349
  stream: ReadableStream<Uint8Array>,
338
350
  signal?: AbortSignal,
351
+ options?: SseReadOptions,
339
352
  ): AsyncGenerator<ServerSentEvent> {
340
353
  const lineBuffer = new ConcatSink();
341
354
  const state: SseEventState = { event: null, data: null, raw: [] };
342
355
  const source = createAbortableStream(stream, signal);
356
+ let eventBytes = 0;
357
+ let totalBytes = 0;
343
358
  try {
344
359
  for await (const chunk of source) {
345
- for (const line of lineBuffer.appendAndFlushLines(chunk)) {
360
+ totalBytes += chunk.length;
361
+ if (options?.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
362
+ throw new Error("SSE stream exceeds size limit");
363
+ }
364
+ for (const line of lineBuffer.appendAndFlushLines(
365
+ chunk,
366
+ () => (options?.maxEventBytes ?? Infinity) - eventBytes,
367
+ )) {
368
+ eventBytes += line.length + 1;
346
369
  const event = pushSseLine(line, state);
370
+ if (line.length === 0 || (line.length === 1 && line[0] === 0x0d)) eventBytes = 0;
347
371
  if (event) yield event;
348
372
  }
349
373
  }