skill-family-harness-node 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ <!-- release-skill:changelog:start version=0.13.0 locale=en baseline=sha256:ba0667eb4b4805aafc2423cd79c04df3c835b8ae9bb07391867175f6ad96c36b -->
4
+ ## [0.13.0] - 2026-08-26
5
+
6
+ Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
7
+
8
+ ### Added
9
+
10
+ - Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
11
+
12
+ ### Changed
13
+
14
+ - Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
15
+
16
+ ### Upgrade Notes
17
+
18
+ Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
19
+ <!-- release-skill:changelog:end version=0.13.0 locale=en -->
20
+
21
+
3
22
  <!-- release-skill:changelog:start version=0.12.0 locale=en baseline=sha256:0074df3a947706f6cd6d8593968a348cd4d3f38d8f2bf75f15a04c8183d76618 -->
4
23
  ## [0.12.0] - 2026-08-26
5
24
 
@@ -1,5 +1,24 @@
1
1
  # 变更日志
2
2
 
3
+ <!-- release-skill:changelog:start version=0.13.0 locale=zh-CN baseline=sha256:1356b2ecff87da4a8bbe5bc04980b125ddf181873ff2b536be87a2e246b59609 -->
4
+ ## [0.13.0] - 2026-08-26
5
+
6
+ Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
7
+
8
+ ### 新增
9
+
10
+ - 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
11
+
12
+ ### 变更
13
+
14
+ - 为 superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
15
+
16
+ ### 升级说明
17
+
18
+ Contracts 与 Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
19
+ <!-- release-skill:changelog:end version=0.13.0 locale=zh-CN -->
20
+
21
+
3
22
  <!-- release-skill:changelog:start version=0.12.0 locale=zh-CN baseline=sha256:2e7db41a08c30d9e0bdc16b4cd27853104ef79dc02f7e9925a1383a6546f5111 -->
4
23
  ## [0.12.0] - 2026-08-26
5
24
 
package/README.md CHANGED
@@ -4,22 +4,26 @@
4
4
 
5
5
  # skill-family-harness-node
6
6
 
7
- <!-- release-skill:release-version: 0.12.0 -->
7
+ <!-- release-skill:release-version: 0.13.0 -->
8
8
 
9
9
  The **single default Node implementation** of the Contracts mechanism protocol. This is a thin runtime: it only implements the mechanism protocol, introduces no business semantics, and does not provide a second-language implementation.
10
10
 
11
11
  <!-- release-skill:managed:start id=latest-release -->
12
- **0.12.0** (2026-08-26)
12
+ **0.13.0** (2026-08-26)
13
13
 
14
- Harness 0.12.0 joins the lockstep Foundation release with its existing process and filesystem mechanisms unchanged.
14
+ Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
15
+
16
+ **Added**
17
+
18
+ - Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
15
19
 
16
20
  **Changed**
17
21
 
18
- - Pins skill-family-contracts to 0.12.0 for the five-host verification extension implemented by Engineering Kit.
22
+ - Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
19
23
 
20
24
  **Upgrade Notes**
21
25
 
22
- No new Harness API or runtime behavior is introduced. Existing process supervision, raw-byte sinks, bound reads, and digest mechanisms remain the shared implementation; host-specific protocol rules belong to Engineering Kit.
26
+ Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
23
27
  <!-- release-skill:managed:end id=latest-release -->
24
28
 
25
29
  ## Problem It Solves
@@ -32,15 +36,17 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
32
36
 
33
37
  ## Installation and Minimal Example
34
38
 
39
+ Version 0.13.0 is not published. The registry command below is for use after publication; this iteration installs the three local candidate tarballs in an isolated directory.
40
+
35
41
  ```sh
36
- npm install skill-family-harness-node@0.12.0
42
+ npm install skill-family-harness-node@0.13.0
37
43
  npm info skill-family-harness-node --help
38
44
  ```
39
45
 
40
46
  The minimal example shows validating a contract document inside Node:
41
47
 
42
48
  ```js
43
- // Run from an empty directory: npm install skill-family-harness-node@0.12.0
49
+ // Run from an empty directory: npm install skill-family-harness-node@0.13.0
44
50
  import { validateContractDocument } from "skill-family-harness-node";
45
51
 
46
52
  const document = {
@@ -218,3 +224,9 @@ Mechanism failures uniformly throw `SFC2004` (EXECUTION_FAILED), with `details.k
218
224
  - Package-local source: `src/*.mjs`.
219
225
  - Package-local candidate source: `candidate/quickstart-profile.mjs`; canonical public import: `skill-family-harness-node/quickstart-profile`; historical migration alias: `skill-family-harness-node/candidate/quickstart-profile`.
220
226
  <!-- agent-quick-reference:end -->
227
+
228
+ ## Complete Plugin Candidate
229
+
230
+ The candidate observeFilesystemTree({ root, rootBinding }) reads complete tree facts. Existing superviseProcess accepts optional per-stream raw-byte caps. Observing a payload does not accept it.
231
+
232
+ Version 0.13.0 is a local source candidate and is not published. Consume the three locally verified tarballs; a version marker, unit test or successful install is not complete host qualification or release approval.
package/README.zh-CN.md CHANGED
@@ -5,22 +5,26 @@
5
5
 
6
6
  # skill-family-harness-node
7
7
 
8
- <!-- release-skill:release-version: 0.12.0 -->
8
+ <!-- release-skill:release-version: 0.13.0 -->
9
9
 
10
10
  Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
11
11
 
12
12
  <!-- release-skill:managed:start id=latest-release -->
13
- **0.12.0** (2026-08-26)
13
+ **0.13.0** (2026-08-26)
14
14
 
15
- Harness 0.12.0 随 Foundation 三包锁步升级,既有进程与文件系统机制保持不变。
15
+ Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
16
+
17
+ **新增**
18
+
19
+ - 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
16
20
 
17
21
  **变更**
18
22
 
19
- - skill-family-contracts 精确依赖更新为 0.12.0,支持 Engineering Kit 的五平台验证扩展。
23
+ - superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
20
24
 
21
25
  **升级说明**
22
26
 
23
- 本版本不新增 Harness API,也不改变运行行为。进程监督、原始字节输出、绑定读取和摘要继续复用既有机制;宿主专属的协议规则由 Engineering Kit 实现。
27
+ Contracts Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
24
28
  <!-- release-skill:managed:end id=latest-release -->
25
29
 
26
30
  ## 解决的问题
@@ -33,15 +37,17 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
33
37
 
34
38
  ## 安装和最小示例
35
39
 
40
+ 0.13.0 尚未发布。下面的 registry 安装命令供发布后使用;本轮验证应在隔离目录安装三个本地候选 tarball。
41
+
36
42
  ```sh
37
- npm install skill-family-harness-node@0.12.0
43
+ npm install skill-family-harness-node@0.13.0
38
44
  npm info skill-family-harness-node --help
39
45
  ```
40
46
 
41
47
  最小示例演示在 Node 内校验一份契约文档:
42
48
 
43
49
  ```js
44
- // 从空目录运行:npm install skill-family-harness-node@0.12.0
50
+ // 从空目录运行:npm install skill-family-harness-node@0.13.0
45
51
  import { validateContractDocument } from "skill-family-harness-node";
46
52
 
47
53
  const document = {
@@ -218,3 +224,9 @@ v2 机制会重算每个 path-backed output 和 evidence Resource 的真实字
218
224
  - 包内源:`src/*.mjs`。
219
225
  - 包内 Candidate 源:`candidate/quickstart-profile.mjs`;规范公共导入:`skill-family-harness-node/quickstart-profile`;历史迁移别名:`skill-family-harness-node/candidate/quickstart-profile`。
220
226
  <!-- agent-quick-reference:end -->
227
+
228
+ ## 完整插件候选能力
229
+
230
+ 新增候选 observeFilesystemTree({ root, rootBinding }) 读取完整树事实;既有 superviseProcess 支持可选每流原始字节上限。观察完成不等于接受载荷。
231
+
232
+ 0.13.0 为本地源码候选,尚未发布。消费本地已验证的三包 tarball,不能把版本标记、单元测试或安装成功当作完整宿主资格与发布批准。
package/SECURITY.md CHANGED
@@ -4,10 +4,10 @@
4
4
 
5
5
  | 版本 | 安全支持状态 |
6
6
  | --- | --- |
7
- | 0.1.x | 当前维护线:接受安全报告 |
7
+ | 0.13.x | 当前维护线:接受安全报告 |
8
8
  | 0.0.0 | 未发布的内部骨架版本,不在公开支持范围 |
9
9
 
10
- 安全修复只承诺在最新的 0.1.x 线上提供;更早版本不回移修复。
10
+ 安全修复只承诺在最新的 0.13.x 线上提供;更早版本不回移修复。
11
11
 
12
12
  ## 报告漏洞
13
13
 
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "@pnpm/lockfile.fs": "1001.1.35",
12
12
  "ipaddr.js": "2.5.0",
13
- "skill-family-contracts": "0.12.0",
13
+ "skill-family-contracts": "0.13.0",
14
14
  "yaml": "2.9.0"
15
15
  },
16
16
  "description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
@@ -48,7 +48,7 @@
48
48
  "url": "https://github.com/ifoohoo/skill-family-harness-node.git"
49
49
  },
50
50
  "type": "module",
51
- "version": "0.12.0",
51
+ "version": "0.13.0",
52
52
  "scripts": {
53
53
  "check": "node --test",
54
54
  "test": "node --test"
@@ -0,0 +1,19 @@
1
+ version: 0.13.0
2
+ date: 2026-08-26
3
+ locales:
4
+ en:
5
+ summary: Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
6
+ changes:
7
+ added:
8
+ - Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
9
+ changed:
10
+ - Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
11
+ upgradeNotes: Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
12
+ zh-CN:
13
+ summary: Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
14
+ changes:
15
+ added:
16
+ - 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
17
+ changed:
18
+ - 为 superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
19
+ upgradeNotes: Contracts 与 Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
@@ -161,7 +161,7 @@ export async function createFilesystemRootBinding(root) {
161
161
  }
162
162
  }
163
163
 
164
- function assertRelativePath(relPath) {
164
+ export function assertRelativePath(relPath) {
165
165
  if (typeof relPath !== "string" || relPath.length === 0 || relPath.includes("\0") ||
166
166
  relPath.includes("\\") || path.posix.isAbsolute(relPath)) {
167
167
  throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "bound read path is not a contained relative path");
@@ -269,6 +269,7 @@ export async function readFileBound(root, relPath, { rootBinding, encoding, expe
269
269
  sha256,
270
270
  bytes: bytes.length,
271
271
  mode: Number(native.leafMode),
272
+ statMode: Number(native.statMode),
272
273
  rootMode: captured.identity.mode,
273
274
  });
274
275
  } finally {
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { digestDocument } from "skill-family-contracts";
3
+ import { digestBytes } from "./closure.mjs";
4
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
5
+ import { assertRelativePath, createFilesystemRootBinding } from "./bound-read.mjs";
6
+ import { loadNativeBoundReadAddon } from "./native/loader.mjs";
7
+
8
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
9
+
10
+ /**
11
+ * Observe every directory and regular-file member of one already bound tree.
12
+ * Native code opens each member relative to the root and supplies its bytes
13
+ * and descriptor facts; this layer only derives the public contract shape.
14
+ */
15
+ export async function observeFilesystemTree({ root, rootBinding } = {}) {
16
+ if (typeof root !== "string" || !path.isAbsolute(root) || path.normalize(root) !== root) {
17
+ throw new TypeError("observeFilesystemTree: root must be a normalized absolute path");
18
+ }
19
+ if (!rootBinding || typeof rootBinding !== "object" ||
20
+ typeof rootBinding.digest !== "string" || !SHA256_PATTERN.test(rootBinding.digest)) {
21
+ throw new TypeError("observeFilesystemTree: rootBinding is required");
22
+ }
23
+ const startBinding = await createFilesystemRootBinding(root);
24
+ if (startBinding.digest !== rootBinding.digest) {
25
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "approved root binding does not match the current root", {
26
+ boundReadDisposition: "boundary-indeterminate",
27
+ });
28
+ }
29
+ const { addon } = await loadNativeBoundReadAddon();
30
+ if (typeof addon.observeFilesystemTreeNative !== "function") {
31
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSUPPORTED_PLATFORM, "native filesystem observation is unavailable in this prebuild");
32
+ }
33
+ let native;
34
+ try {
35
+ native = addon.observeFilesystemTreeNative(root);
36
+ } catch (cause) {
37
+ throw mechanismError(HARNESS_ERROR_KINDS.READ_FAILED, "filesystem tree observation failed", {
38
+ cause: cause?.message ?? "unknown",
39
+ });
40
+ }
41
+ const endBinding = await createFilesystemRootBinding(root);
42
+ if (endBinding.digest !== rootBinding.digest || native.rootDevice === undefined || native.rootInode === undefined ||
43
+ Number(native.rootMode) !== Number.parseInt(native.rootMode, 10)) {
44
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "root identity changed during filesystem tree observation", {
45
+ boundReadDisposition: "boundary-indeterminate",
46
+ });
47
+ }
48
+ const paths = new Set();
49
+ const members = Array.from(native.members ?? [], (member) => {
50
+ assertRelativePath(member.path);
51
+ const alias = member.path.toLowerCase();
52
+ if (paths.has(alias)) throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "filesystem tree contains duplicate or case-alias member paths");
53
+ paths.add(alias);
54
+ if (member.type === "directory") {
55
+ return { path: member.path, type: "directory", statMode: Number(member.statMode) };
56
+ }
57
+ const bytes = Buffer.from(member.content);
58
+ return {
59
+ path: member.path,
60
+ type: "file",
61
+ sha256: digestBytes(bytes),
62
+ bytes: bytes.length,
63
+ statMode: Number(member.statMode),
64
+ contentBase64: bytes.toString("base64"),
65
+ };
66
+ }).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
67
+ return Object.freeze({
68
+ schemaVersion: 1,
69
+ kind: "skill-family.filesystem-tree-observation",
70
+ rootBinding,
71
+ members: Object.freeze(members.map((member) => Object.freeze(member))),
72
+ membersDigest: digestDocument(members),
73
+ });
74
+ }
package/src/index.mjs CHANGED
@@ -49,6 +49,10 @@ export { HarnessError, HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs"
49
49
 
50
50
  export { classifyPathInput, resolveContained, readFileContained } from "./paths.mjs";
51
51
 
52
+ // Complete bound-tree facts for plugin verification. The native reader owns
53
+ // descriptor-relative traversal and same-descriptor fstat/read checks.
54
+ export { observeFilesystemTree } from "./filesystem-observation.mjs";
55
+
52
56
  // Strict contained read (FG-1): no-follow, regular-file identity assertion,
53
57
  // and a post-read sha256 digest receipt for existing authority files.
54
58
  export { readFileStrict } from "./strict-read.mjs";
@@ -16,7 +16,7 @@ const PLATFORMS = Object.freeze({
16
16
  "linux-arm64-gnu": { os: "linux", arch: "arm64", libc: "glibc" },
17
17
  "linux-x64-gnu": { os: "linux", arch: "x64", libc: "glibc" },
18
18
  });
19
- const EXPORTS = Object.freeze(["closeParentDirectory", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
19
+ const EXPORTS = Object.freeze(["closeParentDirectory", "observeFilesystemTreeNative", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
20
20
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
21
21
  const bindingGypSha256 = sha256(readFileSync(BINDING_GYP));
22
22
 
@@ -11,6 +11,7 @@
11
11
  #include <string.h>
12
12
  #include <sys/stat.h>
13
13
  #include <sys/types.h>
14
+ #include <dirent.h>
14
15
  #include <unistd.h>
15
16
 
16
17
  #if defined(__APPLE__)
@@ -254,6 +255,8 @@ static napi_value ReadFileBoundNative(napi_env env, napi_callback_info info) {
254
255
  NAPI_OK(napi_set_named_property(env, result, "rootMode", value), "cannot set root mode");
255
256
  NAPI_OK(napi_create_int64(env, (int64_t)(leaf_stat.st_mode & 0777), &value), "cannot encode leaf mode");
256
257
  NAPI_OK(napi_set_named_property(env, result, "leafMode", value), "cannot set leaf mode");
258
+ NAPI_OK(napi_create_int64(env, (int64_t)leaf_stat.st_mode, &value), "cannot encode leaf stat mode");
259
+ NAPI_OK(napi_set_named_property(env, result, "statMode", value), "cannot set leaf stat mode");
257
260
  char decimal[64];
258
261
  snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_dev);
259
262
  if (set_string(env, result, "rootDevice", decimal) == NULL) return NULL;
@@ -418,10 +421,151 @@ static napi_value RenameDirectoryNoReplace(napi_env env, napi_callback_info info
418
421
  return result;
419
422
  }
420
423
 
424
+ /* A complete bound-tree observation is deliberately implemented beside the
425
+ * existing descriptor-relative reader. Every regular member is opened with
426
+ * openat(O_NOFOLLOW), fstat'd on that descriptor, read from that descriptor,
427
+ * and fstat'd again before the descriptor is closed. JavaScript only receives
428
+ * the bytes and facts from that one descriptor; it never supplements them
429
+ * with path-based lstat calls. */
430
+ static int same_member_stat(const struct stat *before, const struct stat *after) {
431
+ return before->st_dev == after->st_dev && before->st_ino == after->st_ino &&
432
+ before->st_mode == after->st_mode && before->st_nlink == after->st_nlink &&
433
+ before->st_size == after->st_size && before->st_mtime == after->st_mtime &&
434
+ before->st_ctime == after->st_ctime;
435
+ }
436
+
437
+ static int set_int64(napi_env env, napi_value object, const char *name, int64_t value) {
438
+ napi_value encoded;
439
+ if (napi_create_int64(env, value, &encoded) != napi_ok ||
440
+ napi_set_named_property(env, object, name, encoded) != napi_ok) {
441
+ napi_throw_error(env, NULL, "cannot encode filesystem observation fact");
442
+ return 0;
443
+ }
444
+ return 1;
445
+ }
446
+
447
+ static int set_member_string(napi_env env, napi_value object, const char *name, const char *value) {
448
+ return set_string(env, object, name, value) != NULL;
449
+ }
450
+
451
+ static int append_directory_member(napi_env env, napi_value members, uint32_t *count,
452
+ const char *relative, const struct stat *st) {
453
+ napi_value member;
454
+ if (napi_create_object(env, &member) != napi_ok) { napi_throw_error(env, NULL, "cannot create directory observation"); return 0; }
455
+ if (!set_member_string(env, member, "path", relative) ||
456
+ !set_member_string(env, member, "type", "directory") ||
457
+ !set_int64(env, member, "statMode", (int64_t)st->st_mode)) return 0;
458
+ if (napi_set_element(env, members, (*count)++, member) != napi_ok) { napi_throw_error(env, NULL, "cannot append directory observation"); return 0; }
459
+ return 1;
460
+ }
461
+
462
+ static int append_file_member(napi_env env, napi_value members, uint32_t *count,
463
+ const char *relative, const struct stat *st,
464
+ const unsigned char *bytes, size_t length) {
465
+ napi_value member, buffer;
466
+ if (napi_create_object(env, &member) != napi_ok) { napi_throw_error(env, NULL, "cannot create file observation"); return 0; }
467
+ if (!set_member_string(env, member, "path", relative) ||
468
+ !set_member_string(env, member, "type", "file") ||
469
+ !set_int64(env, member, "statMode", (int64_t)st->st_mode) ||
470
+ !set_int64(env, member, "bytes", (int64_t)length)) return 0;
471
+ if (napi_create_buffer_copy(env, length, bytes, NULL, &buffer) != napi_ok ||
472
+ napi_set_named_property(env, member, "content", buffer) != napi_ok ||
473
+ napi_set_element(env, members, (*count)++, member) != napi_ok) { napi_throw_error(env, NULL, "cannot append file observation"); return 0; }
474
+ return 1;
475
+ }
476
+
477
+ static int observe_directory(napi_env env, int dir_fd, const char *prefix,
478
+ napi_value members, uint32_t *count) {
479
+ int scan_fd = dup(dir_fd);
480
+ if (scan_fd < 0) { napi_throw_error(env, NULL, "filesystem observation directory duplication failed"); return 0; }
481
+ DIR *directory = fdopendir(scan_fd);
482
+ if (directory == NULL) { close(scan_fd); napi_throw_error(env, NULL, "filesystem observation directory open failed"); return 0; }
483
+ struct dirent *entry;
484
+ while ((entry = readdir(directory)) != NULL) {
485
+ if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
486
+ char relative[PATH_MAX];
487
+ int written = snprintf(relative, sizeof(relative), "%s%s%s", prefix,
488
+ prefix[0] == '\0' ? "" : "/", entry->d_name);
489
+ if (written < 1 || (size_t)written >= sizeof(relative)) {
490
+ closedir(directory); napi_throw_error(env, NULL, "filesystem observation path is too long"); return 0;
491
+ }
492
+ struct stat named_stat;
493
+ if (fstatat(dir_fd, entry->d_name, &named_stat, AT_SYMLINK_NOFOLLOW) != 0 ||
494
+ (!S_ISDIR(named_stat.st_mode) && !S_ISREG(named_stat.st_mode))) {
495
+ closedir(directory); napi_throw_error(env, NULL, "filesystem observation encountered a symlink, special file, or changed member"); return 0;
496
+ }
497
+ int child_fd = openat(dir_fd, entry->d_name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
498
+ if (child_fd < 0) { closedir(directory); napi_throw_error(env, NULL, "filesystem observation member changed or cannot be opened"); return 0; }
499
+ struct stat before;
500
+ if (fstat(child_fd, &before) != 0) { close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation member cannot be stat'd"); return 0; }
501
+ if (S_ISDIR(before.st_mode)) {
502
+ if (!append_directory_member(env, members, count, relative, &before) ||
503
+ !observe_directory(env, child_fd, relative, members, count)) {
504
+ close(child_fd); closedir(directory); return 0;
505
+ }
506
+ close(child_fd);
507
+ continue;
508
+ }
509
+ if (!S_ISREG(before.st_mode) || before.st_nlink != 1 || before.st_size < 0) {
510
+ close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation encountered an unsupported member"); return 0;
511
+ }
512
+ size_t length = 0, capacity = before.st_size > 0 ? (size_t)before.st_size : 1;
513
+ unsigned char *bytes = malloc(capacity);
514
+ if (bytes == NULL) { close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation allocation failed"); return 0; }
515
+ for (;;) {
516
+ if (length == capacity) {
517
+ if (capacity > SIZE_MAX / 2) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation file is too large"); return 0; }
518
+ size_t next = capacity * 2; unsigned char *grown = realloc(bytes, next);
519
+ if (grown == NULL) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation allocation failed"); return 0; }
520
+ bytes = grown; capacity = next;
521
+ }
522
+ ssize_t read_count = read(child_fd, bytes + length, capacity - length);
523
+ if (read_count < 0) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation read failed"); return 0; }
524
+ if (read_count == 0) break;
525
+ length += (size_t)read_count;
526
+ }
527
+ struct stat after;
528
+ if (fstat(child_fd, &after) != 0 || !same_member_stat(&before, &after) || (size_t)after.st_size != length) {
529
+ free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation member changed while being read"); return 0;
530
+ }
531
+ int ok = append_file_member(env, members, count, relative, &after, bytes, length);
532
+ free(bytes); close(child_fd);
533
+ if (!ok) { closedir(directory); return 0; }
534
+ }
535
+ if (closedir(directory) != 0) { napi_throw_error(env, NULL, "filesystem observation directory close failed"); return 0; }
536
+ return 1;
537
+ }
538
+
539
+ static napi_value ObserveFilesystemTreeNative(napi_env env, napi_callback_info info) {
540
+ size_t argc = 1; napi_value argv[1], result, members;
541
+ char root[PATH_MAX];
542
+ NAPI_OK(napi_get_cb_info(env, info, &argc, argv, NULL, NULL), "cannot read native arguments");
543
+ if (argc != 1 || !read_string(env, argv[0], root, sizeof(root), "root must be a bounded absolute path") || root[0] != '/') return NULL;
544
+ int root_fd = open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
545
+ if (root_fd < 0) return throw_last(env, "filesystem observation root cannot be opened");
546
+ struct stat root_stat;
547
+ if (fstat(root_fd, &root_stat) != 0 || !S_ISDIR(root_stat.st_mode)) { close(root_fd); return throw_last(env, "filesystem observation root is not a directory"); }
548
+ NAPI_OK(napi_create_object(env, &result), "cannot create filesystem observation");
549
+ NAPI_OK(napi_create_array(env, &members), "cannot create filesystem observation members");
550
+ uint32_t count = 0;
551
+ if (!observe_directory(env, root_fd, "", members, &count)) { close(root_fd); return NULL; }
552
+ close(root_fd);
553
+ NAPI_OK(napi_set_named_property(env, result, "members", members), "cannot set filesystem observation members");
554
+ if (!set_int64(env, result, "rootMode", (int64_t)root_stat.st_mode)) return NULL;
555
+ char decimal[64];
556
+ snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_dev);
557
+ if (!set_member_string(env, result, "rootDevice", decimal)) return NULL;
558
+ snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_ino);
559
+ if (!set_member_string(env, result, "rootInode", decimal)) return NULL;
560
+ return result;
561
+ }
562
+
421
563
  static napi_value Init(napi_env env, napi_value exports) {
422
564
  napi_value value;
423
565
  NAPI_OK(napi_create_function(env, "readFileBoundNative", NAPI_AUTO_LENGTH, ReadFileBoundNative, NULL, &value), "cannot export bound read");
424
566
  NAPI_OK(napi_set_named_property(env, exports, "readFileBoundNative", value), "cannot export bound read");
567
+ NAPI_OK(napi_create_function(env, "observeFilesystemTreeNative", NAPI_AUTO_LENGTH, ObserveFilesystemTreeNative, NULL, &value), "cannot export filesystem observation");
568
+ NAPI_OK(napi_set_named_property(env, exports, "observeFilesystemTreeNative", value), "cannot export filesystem observation");
425
569
  NAPI_OK(napi_create_function(env, "openParentDirectory", NAPI_AUTO_LENGTH, OpenParentDirectory, NULL, &value), "cannot export openParentDirectory");
426
570
  NAPI_OK(napi_set_named_property(env, exports, "openParentDirectory", value), "cannot export openParentDirectory");
427
571
  NAPI_OK(napi_create_function(env, "closeParentDirectory", NAPI_AUTO_LENGTH, CloseParentDirectory, NULL, &value), "cannot export closeParentDirectory");
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  const PACKAGE_ROOT = path.dirname(fileURLToPath(import.meta.url));
10
10
  const MANIFEST_PATH = path.join(PACKAGE_ROOT, "prebuild-manifest.json");
11
11
  const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
12
- const EXPECTED_EXPORTS = Object.freeze(["closeParentDirectory", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
12
+ const EXPECTED_EXPORTS = Object.freeze(["closeParentDirectory", "observeFilesystemTreeNative", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
13
13
  const PLATFORM_KEYS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-arm64-gnu", "linux-x64-gnu"]);
14
14
 
15
15
  function fail(message) {
@@ -10,10 +10,11 @@
10
10
  "libc": "none",
11
11
  "binary": "prebuilds/darwin-arm64/bound_read.darwin-arm64.node",
12
12
  "mode": 420,
13
- "sha256": "edf0be6988f0c0d26ec03860e55d81be8ddf85fd82d29aba4ab6963bd58f9651",
13
+ "sha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde",
14
14
  "napi": 10,
15
15
  "exports": [
16
16
  "closeParentDirectory",
17
+ "observeFilesystemTreeNative",
17
18
  "openParentDirectory",
18
19
  "platform",
19
20
  "readFileBoundNative",
@@ -27,10 +28,11 @@
27
28
  "libc": "none",
28
29
  "binary": "prebuilds/darwin-x64/bound_read.darwin-x64.node",
29
30
  "mode": 420,
30
- "sha256": "cf11633c8ea804a4c9af58eb205852d38eb6f49979bdad21abc93ba49184ac41",
31
+ "sha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664",
31
32
  "napi": 10,
32
33
  "exports": [
33
34
  "closeParentDirectory",
35
+ "observeFilesystemTreeNative",
34
36
  "openParentDirectory",
35
37
  "platform",
36
38
  "readFileBoundNative",
@@ -44,10 +46,11 @@
44
46
  "libc": "glibc",
45
47
  "binary": "prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node",
46
48
  "mode": 420,
47
- "sha256": "577db2c4b200ecddf0e5807034a57afa0f4d98fd6ee7cdbaeb358f3ff05313ae",
49
+ "sha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1",
48
50
  "napi": 10,
49
51
  "exports": [
50
52
  "closeParentDirectory",
53
+ "observeFilesystemTreeNative",
51
54
  "openParentDirectory",
52
55
  "platform",
53
56
  "readFileBoundNative",
@@ -61,10 +64,11 @@
61
64
  "libc": "glibc",
62
65
  "binary": "prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node",
63
66
  "mode": 420,
64
- "sha256": "f12ed96d4b5a338d87232a074b5d95117d81c5fdeb31f79c664a93c17f28a809",
67
+ "sha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc",
65
68
  "napi": 10,
66
69
  "exports": [
67
70
  "closeParentDirectory",
71
+ "observeFilesystemTreeNative",
68
72
  "openParentDirectory",
69
73
  "platform",
70
74
  "readFileBoundNative",
@@ -8,33 +8,33 @@
8
8
  },
9
9
  "source": {
10
10
  "path": "packages/skill-family-harness-node/src/native/bound_read.c",
11
- "sha256": "58928393adc6921a399685446784efc7eead3987b99378ffd7ac23287d4ccf74"
11
+ "sha256": "10cfc3ff44343a532629879841f9ae7a12b78d818d596f85af3915cea9832c15"
12
12
  },
13
13
  "buildRecipe": {
14
14
  "path": "packages/skill-family-harness-node/src/native/binding.gyp",
15
15
  "sha256": "1631420afe903b1978a856dbeadd37a655a704cd7ded9d1ab6696f87e1116d7f"
16
16
  },
17
- "manifestSha256": "1ded122cb8692d7ec6038bf79b11baaa330d94e3407cff159b9d94f61ee8337e",
17
+ "manifestSha256": "76a1e6f99ef5980043df4f328bbe9623dfc8cbba6655e008a8bb1d53d9e49ace",
18
18
  "inputs": [
19
19
  {
20
20
  "platformKey": "darwin-arm64",
21
- "platformReceiptSha256": "43a7f1ba15b9811f115bcd4868a8e63e79472731c8027d7744a6db04e69fb2e2",
22
- "binarySha256": "edf0be6988f0c0d26ec03860e55d81be8ddf85fd82d29aba4ab6963bd58f9651"
21
+ "platformReceiptSha256": "eff6c0350d7cb7f949346593af1b65f5791b5545e67eba192ef66bd7af7db93d",
22
+ "binarySha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde"
23
23
  },
24
24
  {
25
25
  "platformKey": "darwin-x64",
26
- "platformReceiptSha256": "4f1db41cd657ddfdf3a10926f7372674b36072bb395ee7b3f2f2851b5b7c0b8d",
27
- "binarySha256": "cf11633c8ea804a4c9af58eb205852d38eb6f49979bdad21abc93ba49184ac41"
26
+ "platformReceiptSha256": "55254e51f14fbe7fa5e6d5a64bf23db3926a49758fbe6afb20bc4c8dbc8b30bb",
27
+ "binarySha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664"
28
28
  },
29
29
  {
30
30
  "platformKey": "linux-arm64-gnu",
31
- "platformReceiptSha256": "f024e7f765ffcd8162fc2d7accc60806a337ff7945315dbbaf2609bdea87001a",
32
- "binarySha256": "577db2c4b200ecddf0e5807034a57afa0f4d98fd6ee7cdbaeb358f3ff05313ae"
31
+ "platformReceiptSha256": "3e4df89d8b211d0700f436314ec6302999e086602bf26d68797d285d40ab7941",
32
+ "binarySha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1"
33
33
  },
34
34
  {
35
35
  "platformKey": "linux-x64-gnu",
36
- "platformReceiptSha256": "aca0b64b4943dfdae39a2870d75a745088694a84f27b7ceafbfded4234a5fb73",
37
- "binarySha256": "f12ed96d4b5a338d87232a074b5d95117d81c5fdeb31f79c664a93c17f28a809"
36
+ "platformReceiptSha256": "672d066df845073da0999f69659e772dec3cf0d6b3e34bf74907a5e7fd5190c9",
37
+ "binarySha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc"
38
38
  }
39
39
  ]
40
40
  }
@@ -4,7 +4,7 @@
4
4
  "status": "stable",
5
5
  "source": {
6
6
  "path": "packages/skill-family-harness-node/src/native/bound_read.c",
7
- "sha256": "58928393adc6921a399685446784efc7eead3987b99378ffd7ac23287d4ccf74"
7
+ "sha256": "10cfc3ff44343a532629879841f9ae7a12b78d818d596f85af3915cea9832c15"
8
8
  },
9
9
  "buildRecipe": {
10
10
  "path": "packages/skill-family-harness-node/src/native/binding.gyp",
@@ -14,25 +14,25 @@
14
14
  {
15
15
  "platformKey": "darwin-arm64",
16
16
  "binary": "prebuilds/darwin-arm64/bound_read.darwin-arm64.node",
17
- "sha256": "edf0be6988f0c0d26ec03860e55d81be8ddf85fd82d29aba4ab6963bd58f9651",
17
+ "sha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde",
18
18
  "license": "Apache-2.0"
19
19
  },
20
20
  {
21
21
  "platformKey": "darwin-x64",
22
22
  "binary": "prebuilds/darwin-x64/bound_read.darwin-x64.node",
23
- "sha256": "cf11633c8ea804a4c9af58eb205852d38eb6f49979bdad21abc93ba49184ac41",
23
+ "sha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664",
24
24
  "license": "Apache-2.0"
25
25
  },
26
26
  {
27
27
  "platformKey": "linux-arm64-gnu",
28
28
  "binary": "prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node",
29
- "sha256": "577db2c4b200ecddf0e5807034a57afa0f4d98fd6ee7cdbaeb358f3ff05313ae",
29
+ "sha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1",
30
30
  "license": "Apache-2.0"
31
31
  },
32
32
  {
33
33
  "platformKey": "linux-x64-gnu",
34
34
  "binary": "prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node",
35
- "sha256": "f12ed96d4b5a338d87232a074b5d95117d81c5fdeb31f79c664a93c17f28a809",
35
+ "sha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc",
36
36
  "license": "Apache-2.0"
37
37
  }
38
38
  ]
@@ -61,6 +61,7 @@ export const WATCHDOG_REASONS = Object.freeze([
61
61
  "no_terminal_result",
62
62
  "residual_process_group",
63
63
  "context_budget_exhausted",
64
+ "output_limit_exceeded",
64
65
  ]);
65
66
 
66
67
  /** Termination reason: the deterministic mapping consumers consume (8 values). */
@@ -73,6 +74,7 @@ export const TERMINATION_REASONS = Object.freeze([
73
74
  "completed",
74
75
  "child_exit",
75
76
  "context_budget_exhausted",
77
+ "output_limit_exceeded",
76
78
  ]);
77
79
 
78
80
  /** Process status of the supervised process (7 values). */
@@ -115,6 +117,25 @@ const TIMEOUT_WATCHDOG_REASONS = new Set([
115
117
  "tool_lease_expired",
116
118
  ]);
117
119
 
120
+ const OUTPUT_LIMIT_FIELDS = Object.freeze(["stdout", "stderr"]);
121
+ const MAX_SAFE_OUTPUT_BYTES = Number.MAX_SAFE_INTEGER;
122
+
123
+ function validateOutputByteLimits(limits) {
124
+ if (limits === undefined) return null;
125
+ if (!isPlainObject(limits)) return "outputByteLimits must be a plain object";
126
+ const fields = Object.keys(limits);
127
+ if (fields.length === 0 || fields.some((field) => !OUTPUT_LIMIT_FIELDS.includes(field))) {
128
+ return "outputByteLimits must contain stdout and/or stderr only";
129
+ }
130
+ for (const field of fields) {
131
+ const value = limits[field];
132
+ if (!Number.isInteger(value) || value < 0 || value > MAX_SAFE_OUTPUT_BYTES) {
133
+ return `outputByteLimits.${field} must be an integer from 0 to Number.MAX_SAFE_INTEGER`;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
118
139
  // ---------------------------------------------------------------------------
119
140
  // Option and policy validation
120
141
  // ---------------------------------------------------------------------------
@@ -164,7 +185,7 @@ function validateOptions(options) {
164
185
  const {
165
186
  command, args, cwd, env,
166
187
  timeoutPolicy, progressPaths, terminalProgressPaths, terminalGraceSeconds,
167
- checkIntervalSeconds, longToolActive, budgetExhausted, evidencePath, rawSink, rawStreamSink,
188
+ checkIntervalSeconds, longToolActive, budgetExhausted, evidencePath, rawSink, rawStreamSink, outputByteLimits,
168
189
  } = options;
169
190
  if (typeof command !== "string" || command.length === 0) {
170
191
  throw new TypeError("superviseProcess: command must be a non-empty string");
@@ -220,6 +241,8 @@ function validateOptions(options) {
220
241
  const problem = validateRawSink(rawSink);
221
242
  if (problem) throw new TypeError(`superviseProcess: ${problem}`);
222
243
  }
244
+ const outputLimitProblem = validateOutputByteLimits(outputByteLimits);
245
+ if (outputLimitProblem) throw new TypeError(`superviseProcess: ${outputLimitProblem}`);
223
246
  const policyProblem = validateTimeoutPolicy(timeoutPolicy);
224
247
  if (policyProblem) {
225
248
  throw mechanismError("timeout-policy-invalid", policyProblem);
@@ -308,8 +331,8 @@ async function openRawSinkFile(captured, filename) {
308
331
  return handle;
309
332
  }
310
333
 
311
- function makeRawStreamState(handle) {
312
- return { handle, hash: createHash("sha256"), bytes: 0, queue: Promise.resolve(), closed: false };
334
+ function makeRawStreamState(handle, limit) {
335
+ return { handle, hash: createHash("sha256"), bytes: 0, limit, queue: Promise.resolve(), closed: false };
313
336
  }
314
337
 
315
338
  function enqueueRawChunk(state, chunk) {
@@ -503,6 +526,7 @@ const WATCHDOG_TO_TERMINATION = Object.freeze({
503
526
  no_terminal_result: "completed",
504
527
  residual_process_group: "child_exit",
505
528
  context_budget_exhausted: "context_budget_exhausted",
529
+ output_limit_exceeded: "output_limit_exceeded",
506
530
  });
507
531
 
508
532
  function mapWatchdogToTermination(watchdogReason) {
@@ -547,6 +571,8 @@ function buildEnvelope({
547
571
  signalSequence,
548
572
  forcedKill,
549
573
  timeoutPolicy,
574
+ outputByteLimits,
575
+ outputLimitExceeded,
550
576
  progressPaths,
551
577
  terminalProgressPaths,
552
578
  terminalSeen,
@@ -564,9 +590,9 @@ function buildEnvelope({
564
590
  sawLeaderExit,
565
591
  );
566
592
  const ok = watchdogReason === "terminal_progress"
567
- ? true
593
+ ? outputLimitExceeded === null
568
594
  : watchdogReason === null
569
- ? exitStatus === 0
595
+ ? exitStatus === 0 && outputLimitExceeded === null
570
596
  : false;
571
597
  const evidence = {
572
598
  command,
@@ -587,6 +613,7 @@ function buildEnvelope({
587
613
  startupProgressPaths: progressPaths,
588
614
  terminalProgressPaths,
589
615
  timeoutPolicy,
616
+ ...(outputByteLimits !== undefined ? { outputByteLimits, outputLimitExceeded } : {}),
590
617
  ...(terminalSeen ? { terminalIdentity: terminalSeen.identity } : {}),
591
618
  ...(terminalSeen?.status !== undefined ? { terminalStatus: terminalSeen.status } : {}),
592
619
  ...(residualGroupCleanupCompleted !== undefined
@@ -634,6 +661,9 @@ function buildEnvelope({
634
661
  * terminal record must persist before the mechanism closes the process
635
662
  * with watchdog_reason terminal_progress.
636
663
  * @param {number} [options.checkIntervalSeconds=1] - supervision cadence.
664
+ * @param {{stdout?: number, stderr?: number}} [options.outputByteLimits] -
665
+ * optional independent raw-byte caps; equal is allowed, first excess
666
+ * immediately enters the existing process-group termination path.
637
667
  * @param {() => boolean} [options.longToolActive] - consumer-owned long-tool
638
668
  * activity probe; enables the tool-lease dimension (requires
639
669
  * toolLeaseSeconds). Stream-idle is suspended while the probe is true.
@@ -679,6 +709,7 @@ export async function superviseProcess(options, deps = {}) {
679
709
  evidencePath,
680
710
  } = options;
681
711
  const sink = options.rawSink ?? null;
712
+ const outputByteLimits = options.outputByteLimits;
682
713
  let rawStreams = null;
683
714
  if (sink !== null) {
684
715
  try {
@@ -692,8 +723,8 @@ export async function superviseProcess(options, deps = {}) {
692
723
  throw error;
693
724
  }
694
725
  rawStreams = {
695
- stdout: makeRawStreamState(stdoutHandle),
696
- stderr: makeRawStreamState(stderrHandle),
726
+ stdout: makeRawStreamState(stdoutHandle, outputByteLimits?.stdout),
727
+ stderr: makeRawStreamState(stderrHandle, outputByteLimits?.stderr),
697
728
  };
698
729
  } catch (error) {
699
730
  if (rawStreams?.stdout) await closeRawStreamState(rawStreams.stdout).catch(() => {});
@@ -759,6 +790,8 @@ export async function superviseProcess(options, deps = {}) {
759
790
  residualGroupCleanupCompleted: undefined,
760
791
  watchdogSignalSucceeded: false,
761
792
  finalizing: false,
793
+ outputLimitExceeded: null,
794
+ outputBytes: { stdout: 0, stderr: 0 },
762
795
  };
763
796
  const signalSequence = [];
764
797
  let interval = null;
@@ -867,6 +900,9 @@ export async function superviseProcess(options, deps = {}) {
867
900
  let watchdogReason = lifecycle.vanish
868
901
  ? "no_terminal_result"
869
902
  : (termination?.reason ?? null);
903
+ if (watchdogReason === null && lifecycle.outputLimitExceeded !== null) {
904
+ watchdogReason = "output_limit_exceeded";
905
+ }
870
906
 
871
907
  // Terminal requirement reconciliation (R9-equivalent): when terminal
872
908
  // progress paths are configured and the close was authoritative
@@ -916,6 +952,8 @@ export async function superviseProcess(options, deps = {}) {
916
952
  signalSequence,
917
953
  forcedKill: termination?.forcedKill === true,
918
954
  timeoutPolicy,
955
+ outputByteLimits,
956
+ outputLimitExceeded: lifecycle.outputLimitExceeded,
919
957
  progressPaths,
920
958
  terminalProgressPaths,
921
959
  terminalSeen,
@@ -1005,6 +1043,8 @@ export async function superviseProcess(options, deps = {}) {
1005
1043
  signalSequence: [],
1006
1044
  forcedKill: false,
1007
1045
  timeoutPolicy,
1046
+ outputByteLimits,
1047
+ outputLimitExceeded: null,
1008
1048
  progressPaths,
1009
1049
  terminalProgressPaths,
1010
1050
  terminalSeen: null,
@@ -1260,6 +1300,24 @@ export async function superviseProcess(options, deps = {}) {
1260
1300
  }
1261
1301
  }
1262
1302
 
1303
+ function handleOutputChunk(stream, chunk) {
1304
+ const bytes = Buffer.from(chunk);
1305
+ lifecycle.outputBytes[stream] += bytes.length;
1306
+ const limit = outputByteLimits?.[stream];
1307
+ let saved = bytes;
1308
+ if (limit !== undefined) {
1309
+ const allowed = Math.max(0, limit - (lifecycle.outputBytes[stream] - bytes.length));
1310
+ saved = bytes.subarray(0, allowed);
1311
+ if (lifecycle.outputBytes[stream] > limit) {
1312
+ lifecycle.outputLimitExceeded ??= stream;
1313
+ if (beginTermination("output_limit_exceeded", d.nowMs())) superviseLifecycle(d.nowMs());
1314
+ }
1315
+ }
1316
+ if (rawStreams && saved.length > 0) {
1317
+ void enqueueRawChunk(rawStreams[stream], saved).catch((error) => { lifecycle.runtimeError = error; });
1318
+ }
1319
+ }
1320
+
1263
1321
  // Wire the child events.
1264
1322
  child.stdout?.on("data", () => {
1265
1323
  lastStreamActivityAtMs = d.nowMs();
@@ -1267,12 +1325,8 @@ export async function superviseProcess(options, deps = {}) {
1267
1325
  child.stderr?.on("data", () => {
1268
1326
  lastStreamActivityAtMs = d.nowMs();
1269
1327
  });
1270
- child.stdout?.on("data", (chunk) => {
1271
- if (rawStreams) void enqueueRawChunk(rawStreams.stdout, chunk).catch((error) => { lifecycle.runtimeError = error; });
1272
- });
1273
- child.stderr?.on("data", (chunk) => {
1274
- if (rawStreams) void enqueueRawChunk(rawStreams.stderr, chunk).catch((error) => { lifecycle.runtimeError = error; });
1275
- });
1328
+ child.stdout?.on("data", (chunk) => handleOutputChunk("stdout", chunk));
1329
+ child.stderr?.on("data", (chunk) => handleOutputChunk("stderr", chunk));
1276
1330
  child.on("exit", (code, signalCode) => {
1277
1331
  lifecycle.leaderResult = { code, signalCode };
1278
1332
  superviseLifecycle(d.nowMs());