deadbyte-mcp 0.15.51 → 0.15.53

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.
Files changed (48) hide show
  1. package/MANIFEST.SHA256 +47 -18
  2. package/PROVENANCE.json +27 -9
  3. package/README.txt +18 -18
  4. package/RELEASE.json +33 -6
  5. package/SBOM.cdx.json +2 -2
  6. package/TEST-MATRIX.json +32 -2
  7. package/bridge/src/remote-call-claims-r60.mjs +5 -0
  8. package/controller/deadbyte-process-policy.json +4 -4
  9. package/docs/R135-AUTONOMOUS-SCOPE.md +21 -0
  10. package/docs/R136-SCOPED-OBSERVER.md +21 -0
  11. package/docs/R137-DESKTOP-COMMANDER-FOUNDATION.md +31 -0
  12. package/docs/R138-COMMANDER-WORKFLOW.md +21 -0
  13. package/docs/R139-COMMANDER-ERGONOMICS.md +17 -0
  14. package/docs/THIRD-PARTY-DESKTOP-COMMANDER-MIT.md +27 -0
  15. package/package.json +1 -1
  16. package/src/autonomous-scope-r135.mjs +91 -0
  17. package/src/commander-config-recovery-r137.mjs +67 -0
  18. package/src/commander-extended-runtime.mjs +22 -5
  19. package/src/commander-external-process-r48.mjs +25 -3
  20. package/src/commander-large-text-r139.mjs +66 -0
  21. package/src/commander-wire-adapter-r47.mjs +63 -12
  22. package/src/deadbyte-cli.mjs +5 -1
  23. package/src/scoped-observer-launcher-r136.mjs +77 -0
  24. package/src/scoped-observer-worker-r136.mjs +65 -0
  25. package/src/version.mjs +1 -1
  26. package/test/commander-wire-contract-r47.test.mjs +8 -5
  27. package/test/r130-release-security-contract.test.mjs +8 -8
  28. package/test/r132-release-identity.test.mjs +9 -9
  29. package/test/r135-autonomous-scope.test.mjs +103 -0
  30. package/test/r136-scoped-command-rights.test.mjs +39 -0
  31. package/test/r136-scoped-observer.test.mjs +89 -0
  32. package/test/r137-commander-config-recovery.test.mjs +111 -0
  33. package/test/r137-commander-three-version-contract.test.mjs +56 -0
  34. package/test/r138-commander-process-pagination.test.mjs +42 -0
  35. package/test/r138-commander-session-list.test.mjs +24 -0
  36. package/test/r138-commander-stream-resume.test.mjs +50 -0
  37. package/test/r139-large-text-edit.test.mjs +51 -0
  38. package/test/r139-large-text-read.test.mjs +46 -0
  39. package/test/r139-process-start-preview.test.mjs +50 -0
  40. package/test/r139-search-partial.test.mjs +42 -0
  41. package/test/r140-large-text-integrity.test.mjs +68 -0
  42. package/test/r141-commander-tail-utf8.test.mjs +55 -0
  43. package/test/r141-real-windows-disk.test.mjs +71 -0
  44. package/test/r142-native-machinefs-authority.test.mjs +127 -0
  45. package/test/r144-claim-terminal-history.test.mjs +55 -0
  46. package/test/r144-controller-broad-roots-cleanup.test.mjs +54 -0
  47. package/test/r59-tool-surface-compat.test.mjs +1 -1
  48. package/test/r74-prepublish-contract.test.mjs +3 -3
@@ -0,0 +1,21 @@
1
+ # R138 — Commander process, output, session workflow
2
+
3
+ Status: DEVELOPMENT successor, not published, not deployed, no authority changes.
4
+
5
+ The native DEADBYTE MCP runtime remains the sole executor; reference Desktop Commander 0.2.52/0.3.0/0.3.1-alpha.2 tool schemas, not vendor servers. This train focuses on making its existing Commander bridge practical without adding Autonomous privilege layers.
6
+
7
+ ## Process list integrity
8
+ `src/commander-external-process-r48.mjs` now validates every observation page, including the SHA-256 snapshot identity, timestamp, max page size and cursor. It detects repeated cursors before re-querying, denies duplicate PIDs within a snapshot, denies changing timestamps or snapshot IDs, and bounds page count. A corrupt observation is rejected before the returned PID can be used for an external kill. The existing identity-bound external termination path is unchanged.
9
+
10
+ ## Output pagination and sessions
11
+ `src/commander-wire-adapter-r47.mjs` now attaches byte-level resume and truncation metadata to the Commander-compatible `read_process_output` structured response. Incremental offset=0 reads return `next_byte_offset` and `more_unread`; historical offset reads return `next_byte_offset:null` because they do not consume the incremental cursor. `earliest_offset` and `truncated` report retention loss explicitly. The public tool input schemas remain exactly as before.
12
+
13
+ `list_sessions` now displays child PID, profile, root, execution state and known exit outcome on one line; empty inventories are readable. Structured session data is untouched. No PID kill authority, raw command execution permissions or standing grants are expanded.
14
+
15
+ ## Tests and provenance
16
+ TDD initial RED reproduced repeated cursor, duplicate PID, inconsistent snapshot timestamps, invalid cursor/identity, missing resume metadata and unreadable session list. The modified tests subsequently passed alongside the R48 Commander wire/managed/external-process regressions. The reference schema parity test for three pinned Desktop Commander versions remains present from R137.
17
+
18
+ The predecessor R137 source manifest SHA-256 is `7fd24ceda2f9369293c79f8da2a12616af822fa4c1224833336e7271a662a643`. Active production pointer SHA-256 is `df926f14fca1c14478342dce5ed41aa1c4b67fb12ae733114f03c8c720ea61e9` (runtime `0.15.45`) and must remain unchanged. R138 still uses package version `0.15.51` for development and must not republish it. Any release requires a distinct version and independent full protected Windows gate.
19
+
20
+ ## Limits
21
+ The Commander bridge has existing file editing/reading/search and process operations, not every Desktop Commander UX feature. R138 proves the named improvements, not full remote parity, production activation, all historical 49 skipped tests, or OS privilege isolation.
@@ -0,0 +1,17 @@
1
+ # R139 — Practical Commander: large text, partial search, startup preview
2
+
3
+ Status: DEVELOPMENT ONLY / not a new npm release / not production activation.
4
+
5
+ Predecessor is R138 (2390 file manifest SHA-256 `8a60c3408dfc18f7593ff6a029a194ee2b503bcc2eed16c2b9ac020d490e863a`). Isolated source `D:\\Projects\\deadbyte-mcp\\development\\deadbyte-mcp-r139-commander-ergonomics-dev`. Runtime remains `0.15.45`; published npm latest remains `0.15.51`.
6
+
7
+ ## Four improvements
8
+ - **Large text reading:** `read_file` for text exceeding the former native 4 MiB compatibility-read cap now uses `src/commander-large-text-r139.mjs` to scan at most 64 MiB with 256 KiB native pages (or the stricter policy cap). It verifies an identical native full-file SHA-256 across pages and rejects malformed cursors, file changes, oversized lines, invalid UTF-8, and oversized files. Only requested line ranges are retained in memory; the full-file scan is O(file size) and may require several native reads. Regular small files and document-specific readers remain unchanged.
9
+ - **Large text edit:** plain-text `edit_block` can modify a uniquely matched string in a UTF-8 file up to 16 MiB (or tighter native policy limit) using the *existing* native hash-preconditioned atomic write. Count of replacements must match the caller. Binary/NUL and invalid UTF-8 targets are rejected; a UTF-8 BOM is preserved. Not an unbounded text writer; DOCX/XLSX native authoring paths are unchanged.
10
+ - **Search results sooner:** `get_more_search_results` now returns discovered matches during an unfinished multi-page search, with `isComplete:false` and `hasMoreResults:true`; it does not disguise discovered matches as zero until all pages finish. Search runtime's existing signed cursor, abort policy and limits are unchanged.
11
+ - **Initial output preview:** `start_process` performs one nonwaiting (`waitMs:0`) 16 KiB maximum read after the process has started and returns any *already available* UTF-8 data in `structuredContent.initial_output` and text. It does not advance the independent process output cursor, so `read_process_output` will still show those bytes again; consumers should deduplicate if mixing both outputs. A failed/binary output probe does **not** disguise a successfully started process as a failed start.
12
+
13
+ ## Security / compatibility
14
+ No command policies, grants, project roots, owner authority, token permissions, npm dependencies, or public tool input schemas are broadened. Existing Desktop Commander 0.2.52, 0.3.0 and 0.3.1-alpha.2 public-schema tests stay in the suite. Most large-file failures remain fail-closed rather than attempting partial destructive edits. A changed file is not rewritten, and no unrestricted subprocess or external vendor server is introduced.
15
+
16
+ ## Evidence
17
+ Observed TDD RED -> GREEN for each feature using `test/r139-*.test.mjs` and existing R47/R48 tests. The new tests include large 5 MiB read/edit, SHA change during paging, binary edit refusal, exact replacement, UTF-8 BOM preservation, oversized edit refusal, partial search and immediate process-output preview. This candidate retains package version `0.15.51` and must not republish that immutable version. Production activation requires a separate decision and live/rollback gates.
@@ -0,0 +1,27 @@
1
+ # Desktop Commander reference — MIT notice
2
+
3
+ The reference design and compatibility tests draw on the Desktop Commander MCP project, by Eduard Ruzga and Desktop Commander Contributors. Source: https://github.com/wonderwhy-er/DesktopCommanderMCP. Exact reference snapshots are described in R137-DESKTOP-COMMANDER-FOUNDATION.md.
4
+
5
+ The following text is the license notice found in the local 0.3.1-alpha.2 distribution. Include it if copying or redistributing covered Desktop Commander software.
6
+
7
+ MIT License
8
+
9
+ Copyright (c) 2024-2025 Eduard Ruzga and Desktop Commander Contributors
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deadbyte-mcp",
3
- "version": "0.15.51",
3
+ "version": "0.15.53",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Authenticated MCP agentic runtime with signed skills, persistent model backends, identity-bound remote devices, durable job supervision, immutable release slots, and separately revocable machine authority.",
@@ -0,0 +1,91 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {existsSync,lstatSync,realpathSync} from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // R135 is an offline POLICY PROJECTION only. It cannot mint a lease or grant,
6
+ // modify an active generation, or make a broad manual Coding grant safe to
7
+ // inherit in the same process. Execution requires a separate proven boundary.
8
+ const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
9
+ const clone=value=>structuredClone(value);
10
+ function realProjectRoot(input){
11
+ if(typeof input!=='string'||!path.isAbsolute(input))throw new Error('real project directory required');
12
+ const candidate=path.resolve(input),disk=path.parse(candidate).root;
13
+ const eq=(a,b)=>process.platform==='win32'?a.toLowerCase()===b.toLowerCase():a===b;
14
+ if(eq(candidate,disk))throw new Error('project-scoped root required');
15
+ try{
16
+ const info=lstatSync(candidate);
17
+ if(!info.isDirectory()||info.isSymbolicLink()||!eq(realpathSync(candidate),candidate)){
18
+ throw new Error('path redirected');
19
+ }
20
+ let cursor=candidate;
21
+ for(;;){
22
+ if(lstatSync(cursor).isSymbolicLink())throw new Error('linked ancestor');
23
+ const parent=path.dirname(cursor);
24
+ if(parent===cursor)break;
25
+ cursor=parent;
26
+ }
27
+ }catch{throw new Error('real project directory required')}
28
+ return candidate;
29
+ }
30
+ export function deriveIsolatedAutonomousScope({manualCodingPolicy,autonomousPolicy,scopeRootId,isolatedStateRoot}={}){
31
+ if(manualCodingPolicy?.schema!=='deadbyte.coding-policy.v1'||manualCodingPolicy.enabled!==true)
32
+ throw new Error('manual Coding policy schema invalid');
33
+ if(autonomousPolicy?.schema!=='deadbyte.autonomous-policy.v1'||autonomousPolicy.enabled!==true)
34
+ throw new Error('Autonomous policy schema invalid');
35
+ if(!/^[A-Za-z0-9._-]{1,64}$/.test(scopeRootId??''))throw new Error('invalid project scope');
36
+ if(!path.isAbsolute(isolatedStateRoot??''))throw new Error('isolated state root must be absolute');
37
+ const scopedRoot=manualCodingPolicy.roots?.[scopeRootId];
38
+ if(!scopedRoot)throw new Error('approved project root missing from Coding policy');
39
+ if(!Array.isArray(autonomousPolicy.root_ids)||autonomousPolicy.root_ids.length!==1||
40
+ autonomousPolicy.root_ids[0]!==scopeRootId)throw new Error('Autonomous root outside approved scope');
41
+ const safeRoot=realProjectRoot(scopedRoot.path);
42
+ const isolated=realProjectRoot(isolatedStateRoot);
43
+ const eq=(a,b)=>process.platform==='win32'?a.toLowerCase()===b.toLowerCase():a===b;
44
+ const manualLease=manualCodingPolicy.lease_file,autonLease=autonomousPolicy.lease_file;
45
+ for(const lease of [manualLease,autonLease]){
46
+ if(typeof lease!=='string'||!path.isAbsolute(lease))throw new Error('manual policy lease invalid');
47
+ const rel=path.relative(isolated,path.resolve(lease));
48
+ if(rel===''||(!path.isAbsolute(rel)&&rel!=='..'&&!rel.startsWith('..'+path.sep))){
49
+ throw new Error('isolated state root overlaps manual Coding lease');
50
+ }
51
+ }
52
+ // Fail closed for any profiles referencing another root or missing command.
53
+ const selectedCommands={};
54
+ for(const [id,profile] of Object.entries(autonomousPolicy.profiles??{})){
55
+ if(profile?.root_id!==scopeRootId)throw new Error('Autonomous profile '+id+' root outside approved scope');
56
+ const command=profile.command_id;
57
+ if(typeof command!=='string'||!Object.hasOwn(manualCodingPolicy.commands??{},command)){
58
+ throw new Error('Autonomous command missing from Coding policy');
59
+ }
60
+ const selected=clone(manualCodingPolicy.commands[command]);
61
+ if(!Array.isArray(selected.root_ids)||!selected.root_ids.includes(scopeRootId)){
62
+ throw new Error('Autonomous command '+command+' outside approved scope');
63
+ }
64
+ selected.root_ids=[scopeRootId];
65
+ selectedCommands[command]=selected;
66
+ }
67
+ if(autonomousPolicy.planner?.backend==='codex_app_server'&&
68
+ autonomousPolicy.planner.root_id!==scopeRootId)throw new Error('planner root outside approved scope');
69
+ const codingPolicy=clone(manualCodingPolicy);
70
+ codingPolicy.roots={[scopeRootId]:{...clone(scopedRoot),path:safeRoot}};
71
+ codingPolicy.commands=selectedCommands;
72
+ codingPolicy.lease_file=path.join(isolated,'runtime','coding-grant.json');
73
+ codingPolicy.evidence_dir=path.join(isolated,'evidence','coding');
74
+ if(existsSync(codingPolicy.lease_file)||existsSync(path.join(isolated,'runtime','autonomous-grant.json'))){
75
+ throw new Error('isolated grant file already present');
76
+ }
77
+ const codingBytes=Buffer.from(JSON.stringify(codingPolicy,null,2)+'\n','utf8');
78
+ const nextAutonomous=clone(autonomousPolicy);
79
+ nextAutonomous.root_ids=[scopeRootId];
80
+ nextAutonomous.coding_policy_sha256=sha(codingBytes);
81
+ nextAutonomous.lease_file=path.join(isolated,'runtime','autonomous-grant.json');
82
+ nextAutonomous.evidence_dir=path.join(isolated,'evidence','autonomous');
83
+ const autonomousBytes=Buffer.from(JSON.stringify(nextAutonomous,null,2)+'\n','utf8');
84
+ return Object.freeze({
85
+ scope_root_id:scopeRootId,isolated_state_root:isolated,
86
+ codingBytes,autonomousBytes,
87
+ codingPolicy,autonomousPolicy:nextAutonomous,
88
+ coding_sha256:sha(codingBytes),autonomous_sha256:sha(autonomousBytes),
89
+ status:'OFFLINE_UNAUTHORIZED_PROJECTION'
90
+ });
91
+ }
@@ -0,0 +1,67 @@
1
+ import {createHash,randomUUID} from 'node:crypto';
2
+ import {readFile,writeFile,rename} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Reference design: Desktop Commander 0.3.0 MIT config-recovery.
6
+ // DEADBYTE independently restricts salvage to NON-AUTHORITY display limits;
7
+ // privileged configuration never flows into this recovery path.
8
+ const MAX_BYTES=1024*1024;
9
+ const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
10
+ const isObject=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
11
+
12
+ export function salvageSafeCommanderLimits(raw){
13
+ if(typeof raw!=='string'||Buffer.byteLength(raw,'utf8')>MAX_BYTES)return {};
14
+ let str=raw.charCodeAt(0)===0xfeff?raw.slice(1):raw;
15
+ if(!str.trimStart().startsWith('{'))return {};
16
+ const base=str.indexOf('{'),ends=[];
17
+ let depth=0,quoted=false,escaped=false;
18
+ for(let i=base;i<str.length;i++){
19
+ const c=str[i];
20
+ if(quoted){if(escaped)escaped=false;else if(c==='\\')escaped=true;else if(c==='"')quoted=false;continue;}
21
+ if(c==='"'){quoted=true;continue;}
22
+ if(c==='{'||c==='['){depth++;continue;}
23
+ if(c==='}'||c===']'){depth--;if(depth===0){ends.push({index:i+1,complete:true});break;}if(depth<0)break;continue;}
24
+ if(c===','&&depth===1)ends.push({index:i,complete:false});
25
+ }
26
+ let obj={};
27
+ for(const item of ends.reverse()){
28
+ try{
29
+ const v=JSON.parse(str.slice(base,item.index)+(item.complete?'':'}'));
30
+ if(isObject(v)){obj=v;break;}
31
+ }catch{}
32
+ }
33
+ const safe={};
34
+ for(const [key,max] of [['fileReadLineLimit',1000],['fileWriteLineLimit',50]]){
35
+ const value=obj[key];
36
+ if(Number.isSafeInteger(value)&&value>=1&&value<=max)safe[key]=value;
37
+ }
38
+ return safe;
39
+ }
40
+
41
+ export async function recoverCorruptCommanderConfig(file,bytes,defaults){
42
+ if(!Buffer.isBuffer(bytes)||bytes.length>MAX_BYTES)throw new Error('Commander config corrupt or oversized; manual inspection required');
43
+ if(!isObject(defaults))throw new Error('recovery defaults invalid');
44
+ const safe=salvageSafeCommanderLimits(bytes.toString('utf8'));
45
+ const next={...structuredClone(defaults),...safe};
46
+ // The only trusted recovered settings are bounded display limits.
47
+ next.telemetryEnabled=false;
48
+ next.allowedDirectories=[];
49
+ next.blockedCommands=structuredClone(defaults.blockedCommands);
50
+ const current=await readFile(file);
51
+ if(sha(current)!==sha(bytes))throw new Error('Commander config changed during recovery; stop');
52
+ const backup=path.basename(file)+'.corrupt.'+Date.now()+'.'+randomUUID();
53
+ const backupPath=path.join(path.dirname(file),backup);
54
+ await writeFile(backupPath,bytes,{flag:'wx',mode:0o600});
55
+ const tmp=file+'.recover-'+randomUUID();
56
+ try{
57
+ await writeFile(tmp,JSON.stringify(next)+'\n',{flag:'wx',mode:0o600});
58
+ const verify=await readFile(file);
59
+ if(sha(verify)!==sha(bytes))throw new Error('Commander config changed during recovery; stop');
60
+ await rename(tmp,file);
61
+ }catch(error){
62
+ const {rm}=await import('node:fs/promises');
63
+ await rm(tmp,{force:true}).catch(()=>{});
64
+ throw error;
65
+ }
66
+ return {config:next,recovery:{status:'recovered',backup_filename:backup,corrupt_sha256:sha(bytes),salvaged_keys:Object.keys(safe)}};
67
+ }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { canonicalJson } from './canonical-json.mjs';
5
5
  import { createDeadbytePdf } from './commander-pdf-r48.mjs';
6
6
  import { DEADBYTE_VERSION } from './version.mjs';
7
+ import {recoverCorruptCommanderConfig} from './commander-config-recovery-r137.mjs';
7
8
 
8
9
  export const COMMANDER_EXTENDED_SCHEMA='deadbyte.commander-extended.v1';
9
10
  const MAX_VALUE=16384,MAX_FEEDBACK=4096,MAX_PREVIEW=65536;
@@ -44,17 +45,33 @@ function makePdf(text){
44
45
  export function createCommanderExtendedRuntime({stateRoot,history=null,prompts=DEFAULT_PROMPTS,resources=[]}={}){
45
46
  if(typeof stateRoot!=='string'||!stateRoot)throw new Error('extended runtime stateRoot required');
46
47
  const configPath=path.join(stateRoot,'commander-config.json'),feedbackDir=path.join(stateRoot,'feedback');
47
- let configCache=null;
48
+ let configCache=null,recovery=null,loadPromise=null;
48
49
  async function loadConfig(){
49
50
  if(configCache)return configCache;
50
- try{configCache={...structuredClone(COMPAT_CONFIG_DEFAULTS),...JSON.parse(await readFile(configPath,'utf8'))};}
51
- catch(e){if(e?.code!=='ENOENT')throw e;configCache=structuredClone(COMPAT_CONFIG_DEFAULTS);}
52
- return configCache;
51
+ if(!loadPromise)loadPromise=(async()=>{
52
+ let bytes;
53
+ try{bytes=await readFile(configPath);}
54
+ catch(e){
55
+ if(e?.code!=='ENOENT')throw e;
56
+ configCache=structuredClone(COMPAT_CONFIG_DEFAULTS);return configCache;
57
+ }
58
+ try{
59
+ const parsed=JSON.parse(bytes.toString('utf8'));
60
+ if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new SyntaxError('config must be an object');
61
+ configCache={...structuredClone(COMPAT_CONFIG_DEFAULTS),...parsed};
62
+ }catch(e){
63
+ if(!(e instanceof SyntaxError))throw e;
64
+ const result=await recoverCorruptCommanderConfig(configPath,bytes,COMPAT_CONFIG_DEFAULTS);
65
+ configCache=result.config;recovery=result.recovery;
66
+ }
67
+ return configCache;
68
+ })();
69
+ try{return await loadPromise;}finally{loadPromise=null;}
53
70
  }
54
71
  async function getConfig({key=null}={}){
55
72
  const cfg=await loadConfig();
56
73
  if(key!==null&&!keyOk(key))throw new Error('config key invalid');
57
- return {schema:COMMANDER_EXTENDED_SCHEMA,status:'ok',config:key===null?structuredClone(cfg):{[key]:cfg[key]},config_sha256:sha256(cfg)};
74
+ return {schema:COMMANDER_EXTENDED_SCHEMA,status:'ok',config:key===null?structuredClone(cfg):{[key]:cfg[key]},config_sha256:sha256(cfg),...(recovery?{recovery:structuredClone(recovery)}:{})};
58
75
  }
59
76
  async function setConfigValue({key,value,expected_sha256}){
60
77
  if(!keyOk(key))throw new Error('config key invalid');
@@ -61,17 +61,39 @@ export function createCommanderExternalProcessRuntime({observationRuntime,hostPo
61
61
  let cursor=null,first=null;
62
62
  const processes=[];
63
63
  const receiptIds=[];
64
+ const cursorSet=new Set(),pidSet=new Set();
64
65
  do{
65
66
  const page=await observationRuntime.processList({limit:PAGE_SIZE,includeExternal:true,cursor});
66
67
  if(page?.status!=='ok'||!Array.isArray(page.processes)) throw new Error('external process observation failed');
68
+ if(typeof page.snapshot_id!=='string'||!/^[0-9a-f]{64}$/.test(page.snapshot_id))
69
+ throw new Error('external process observation snapshot identity invalid');
70
+ if(typeof page.observed_at_utc!=='string'||!ISO_UTC.test(page.observed_at_utc)||
71
+ !Number.isFinite(Date.parse(page.observed_at_utc)))
72
+ throw new Error('external process observation timestamp invalid');
67
73
  if(!first) first=page;
68
- else if(page.snapshot_id!==first.snapshot_id) throw new Error('external process observation snapshot changed during pagination');
74
+ else{
75
+ if(page.snapshot_id!==first.snapshot_id) throw new Error('external process observation snapshot changed during pagination');
76
+ if(page.observed_at_utc!==first.observed_at_utc)
77
+ throw new Error('external process observation time changed during pagination');
78
+ }
79
+ if(page.processes.length>PAGE_SIZE)throw new Error('external process observation page exceeds bounded limit');
69
80
  for(const item of page.processes){
70
81
  if(processes.length>=MAX_PROCESSES) throw new Error('external process observation exceeds bounded process cap');
71
- processes.push(normalizeIdentity(item));
82
+ const identity=normalizeIdentity(item);
83
+ if(pidSet.has(identity.pid))throw new Error('external process observation duplicate PID');
84
+ pidSet.add(identity.pid);
85
+ processes.push(identity);
72
86
  }
73
87
  if(typeof page.receipt_id==='string') receiptIds.push(page.receipt_id);
74
- cursor=page.next_cursor??null;
88
+ const next=page.next_cursor??null;
89
+ if(next!==null){
90
+ if(typeof next!=='string'||next.length<1||next.length>8192)throw new Error('external process observation cursor invalid');
91
+ if(cursorSet.has(next))throw new Error('external process pagination cursor repeated');
92
+ cursorSet.add(next);
93
+ if(cursorSet.size>Math.ceil(MAX_PROCESSES/PAGE_SIZE)+1)
94
+ throw new Error('external process pagination exceeded page limit');
95
+ }
96
+ cursor=next;
75
97
  }while(cursor!==null);
76
98
  return {
77
99
  status:'ok',
@@ -0,0 +1,66 @@
1
+ // Bounded-memory Commander text paging, using only the policy-authorized native filesystem.
2
+ // Never rewrites the file. Pages accumulate a SHA-256 checked against one final native hash.
3
+ import {createHash} from 'node:crypto';
4
+ const SHA=/^[a-f0-9]{64}$/;
5
+ const MAX_SCAN=64*1024*1024;
6
+ const MAX_LINE=4*1024*1024;
7
+
8
+ export async function readCommanderLargeText({runtime,rootId,relativePath,offset=0,length=1000}={}){
9
+ if(!runtime||typeof runtime.read!=='function')throw new Error('native filesystem read unavailable');
10
+ if(!Number.isInteger(offset)||offset<0||!Number.isInteger(length)||length<0||length>10000)
11
+ throw new Error('large text offset or length invalid');
12
+ const policyChunk=runtime.policy?.max_read_bytes??256*1024;
13
+ const chunkSize=Math.min(256*1024,policyChunk);
14
+ if(!Number.isSafeInteger(chunkSize)||chunkSize<1)throw new Error('large text read policy limit invalid');
15
+ const decoder=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true});
16
+ let pending='',position=0,fileSha=null,fileSize=null,totalLines=0,scanned=0,done=false;
17
+ const streamedHash=createHash('sha256');
18
+ const selected=[];
19
+ const emit=line=>{
20
+ if(totalLines>=offset&&selected.length<length)selected.push(line);
21
+ totalLines++;
22
+ };
23
+ const feed=(part,final=false)=>{
24
+ const value=pending+part;
25
+ const re=/\r\n|\r|\n/g;
26
+ let match,from=0;
27
+ while((match=re.exec(value))){
28
+ if(!final&&match[0]==='\r'&&match.index+1===value.length)break;
29
+ emit(value.slice(from,match.index));
30
+ from=match.index+match[0].length;
31
+ }
32
+ pending=value.slice(from);
33
+ if(Buffer.byteLength(pending,'utf8')>MAX_LINE)throw new Error('large text line exceeds bounded read limit');
34
+ if(final)emit(pending);
35
+ };
36
+ while(!done){
37
+ const page=await runtime.read({rootId,relativePath,offset:position,maxBytes:chunkSize,hashFile:false});
38
+ if(Number.isSafeInteger(page.file_size)){
39
+ if(fileSize===null)fileSize=page.file_size;
40
+ else if(fileSize!==page.file_size)throw new Error('large text file changed during paged read: size mismatch');
41
+ if(fileSize>MAX_SCAN)throw new Error('large text file exceeds bounded 64MiB scan limit');
42
+ }
43
+ if(!Number.isSafeInteger(page.offset)||page.offset!==position)throw new Error('large text native page offset drift');
44
+ if(typeof page.data_base64!=='string')throw new Error('large text native payload missing');
45
+ const bytes=Buffer.from(page.data_base64,'base64');
46
+ if(bytes.length>chunkSize||page.next_offset!==position+bytes.length)throw new Error('large text native page cursor invalid');
47
+ if(bytes.length===0&&page.eof!==true)throw new Error('large text read stalled before EOF');
48
+ streamedHash.update(bytes);
49
+ position+=bytes.length;scanned+=bytes.length;
50
+ if(scanned>MAX_SCAN)throw new Error('large text file exceeds bounded 64MiB scan limit');
51
+ feed(decoder.decode(bytes,{stream:true}));
52
+ if(page.eof===true){feed(decoder.decode(),true);done=true;}
53
+ }
54
+ if(fileSize!==null&&fileSize!==position)throw new Error('large text file changed during paged read: size mismatch');
55
+ // One native full-file hash after all delivered bytes binds them to final file contents.
56
+ const closing=await runtime.read({rootId,relativePath,offset:position,maxBytes:1,hashFile:true});
57
+ if(!SHA.test(closing?.file_sha256??''))throw new Error('large text native file identity missing');
58
+ if(closing.offset!==position||closing.next_offset!==position||closing.eof!==true||
59
+ closing.data_base64!==''||!Number.isSafeInteger(closing.file_size)||closing.file_size!==position)
60
+ throw new Error('large text file changed during paged read: closing cursor or size mismatch');
61
+ fileSha=closing.file_sha256;
62
+ if(streamedHash.digest('hex')!==fileSha)throw new Error('large text file changed during paged read: identity mismatch');
63
+ const start=Math.min(offset,totalLines);
64
+ return {content:selected.join('\n')+(start+selected.length<totalLines?'\n':''),
65
+ lineOffset:start,lineCount:selected.length,totalLines,fileSha256:fileSha,bytesScanned:scanned,paged:true};
66
+ }
@@ -5,6 +5,7 @@ import { readCommanderUrl } from './commander-url-reader-r48.mjs';
5
5
  import { readDocxBytes, searchDocxTextBytes, createDocxBytes, editDocxBytes, readXlsxBytes, editXlsxBytes, createXlsxBytes, appendXlsxBytes } from './commander-office-reader-r48.mjs';
6
6
  import { editDeadbytePdf, parseDeadbytePdf, readPdfText } from './commander-pdf-r48.mjs';
7
7
  import { decodeUtf8Strict } from './text-policy.mjs';
8
+ import { readCommanderLargeText } from './commander-large-text-r139.mjs';
8
9
 
9
10
  const sha256Bytes = bytes => createHash('sha256').update(bytes).digest('hex');
10
11
  const RDC_PROCESS_READ_CHUNK=256*1024;
@@ -207,12 +208,26 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
207
208
  const tail=await machineFsRuntime.read({
208
209
  rootId:target.rootId,relativePath:target.relativePath,offset:-policyMax,maxBytes:policyMax,hashFile:false
209
210
  });
210
- let decoded=Buffer.from(tail.data_base64,'base64').toString('utf8').replaceAll('\r\n','\n').replaceAll('\r','\n');
211
+ let tailBytes=Buffer.from(tail.data_base64,'base64');
211
212
  if((tail.offset??0)>0){
212
- const firstBreak=decoded.indexOf('\n');
213
- if(firstBreak<0) throw new Error('text tail window contains no complete line');
214
- decoded=decoded.slice(firstBreak+1);
213
+ const previous=await machineFsRuntime.read({
214
+ rootId:target.rootId,relativePath:target.relativePath,
215
+ offset:tail.offset-1,maxBytes:1,hashFile:false
216
+ });
217
+ const before=Buffer.from(previous.data_base64,'base64');
218
+ if(previous.offset!==tail.offset-1||previous.next_offset!==tail.offset||
219
+ previous.file_size!==tail.file_size||before.length!==1)
220
+ throw new Error('text tail window changed before boundary check');
221
+ const aligned=before[0]===0x0a||(before[0]===0x0d&&tailBytes[0]!==0x0a);
222
+ if(!aligned){
223
+ const firstBreak=tailBytes.findIndex(byte=>byte===0x0a||byte===0x0d);
224
+ if(firstBreak<0) throw new Error('text tail window contains no complete line');
225
+ const crlf=tailBytes[firstBreak]===0x0d&&tailBytes[firstBreak+1]===0x0a;
226
+ tailBytes=tailBytes.subarray(firstBreak+(crlf?2:1));
227
+ }
215
228
  }
229
+ if(tailBytes.includes(0)) throw new Error('text tail window contains NUL byte');
230
+ let decoded=decodeUtf8Strict(tailBytes).replaceAll('\r\n','\n').replaceAll('\r','\n');
216
231
  let all=decoded.split('\n');
217
232
  if(all.length&&all.at(-1)==='') all.pop();
218
233
  const wanted=Math.abs(requestedOffset);
@@ -232,6 +247,13 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
232
247
  hashFile:richDocument
233
248
  });
234
249
  if(richDocument&&raw.eof===false) throw new Error('document exceeds bounded native read limit');
250
+ if(!richDocument&&raw.eof===false&&!imageMime&&ext!=='.xls'){
251
+ const large=await readCommanderLargeText({runtime:machineFsRuntime,
252
+ rootId:target.rootId,relativePath:target.relativePath,
253
+ offset:requestedOffset,length:requestedLength});
254
+ return text(large.content,false,{path:args.path,content:large.content,
255
+ mimeType:'text/plain',isImage:false,isPdf:false,...large,...optionMeta});
256
+ }
235
257
  if(!richDocument&&raw.eof===false) throw new Error('text file exceeds bounded compatibility read limit');
236
258
  const bytes=Buffer.from(raw.data_base64,'base64');
237
259
  const fileSha256=raw.file_sha256??sha256Bytes(bytes);
@@ -477,11 +499,17 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
477
499
  });
478
500
  }
479
501
  if(args.old_string===undefined||args.new_string===undefined) throw new Error('Must provide either old_string + new_string');
480
- const raw=await machineFsRuntime.read({rootId:target.rootId,relativePath:target.relativePath,offset:0,maxBytes:Math.min(4*1024*1024,machineFsRuntime.policy.max_read_bytes??4*1024*1024),hashFile:false});
502
+ const editBound=16*1024*1024;
503
+ const raw=await machineFsRuntime.read({rootId:target.rootId,relativePath:target.relativePath,offset:0,
504
+ maxBytes:Math.min(editBound,machineFsRuntime.policy.max_read_bytes??editBound),hashFile:true});
481
505
  if(raw.eof===false) throw new Error('text edit target exceeds bounded compatibility edit limit');
482
506
  const before=Buffer.from(raw.data_base64,'base64');
507
+ if(before.includes(0))throw new Error('binary text edit target rejected');
508
+ let current;
509
+ try{current=decodeUtf8Strict(before);}catch{throw new Error('invalid UTF-8 in text edit target');}
510
+ if(before.length>=3&&before[0]===0xef&&before[1]===0xbb&&before[2]===0xbf)
511
+ current='\uFEFF'+current;
483
512
  const fileSha256=raw.file_sha256??sha256Bytes(before);
484
- const current=before.toString('utf8');
485
513
  let count=0,index=0;
486
514
  while((index=current.indexOf(args.old_string,index))>=0){count++;index+=Math.max(1,args.old_string.length);}
487
515
  if(count!==args.expected_replacements) throw new Error(`expected ${args.expected_replacements} replacements, found ${count}`);
@@ -652,7 +680,20 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
652
680
  }
653
681
  pidMap.set(compatPid,native.session_id);
654
682
  processCompatState.set(compatPid,{last_read_byte:0});
655
- return text(`Process started with PID ${compatPid} (DEADBYTE session ${native.session_id})\\n`,false,{pid:compatPid,session_id:native.session_id});
683
+ let initialOutput='',previewUnavailable=false,previewTruncated=false;
684
+ try{
685
+ // Optional, non-waiting preview: does not advance the independent output cursor.
686
+ const first=await processRuntime.read({sessionId:native.session_id,offset:0,maxBytes:16384,waitMs:0});
687
+ const data=Buffer.from(first.data_base64??'','base64');
688
+ if(data.includes(0))throw new Error('binary process output preview');
689
+ initialOutput=decodeUtf8Strict(data);
690
+ previewTruncated=(first.stream_head??data.length)>(first.next_offset??data.length);
691
+ }catch{previewUnavailable=true;}
692
+ const summary=`Process started with PID ${compatPid} (DEADBYTE session ${native.session_id})`;
693
+ return text(initialOutput?summary+'\n'+initialOutput:summary+'\n',false,{
694
+ pid:compatPid,session_id:native.session_id,initial_output:initialOutput,
695
+ initial_output_truncated:previewTruncated,output_preview_unavailable:previewUnavailable
696
+ });
656
697
  }
657
698
  function sessionForPid(pid){
658
699
  const sessionId=pidMap.get(pid);
@@ -693,7 +734,9 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
693
734
  state:snapshot.state,exitCode:snapshot.exit_code,truncated:wasTruncated
694
735
  }),false,{
695
736
  pid:args.pid,session_id:sessionId,read_from:readFrom,read_count:readCount,total_lines:retainedRanges.length,
696
- remaining,next_line:readFrom+readCount,stream_head:snapshot.stream_head,state:snapshot.state,exit_code:snapshot.exit_code
737
+ remaining,next_line:readFrom+readCount,stream_head:snapshot.stream_head,state:snapshot.state,exit_code:snapshot.exit_code,
738
+ earliest_offset:snapshot.earliest_offset,truncated:wasTruncated,
739
+ next_byte_offset:state.last_read_byte,more_unread:state.last_read_byte<snapshot.stream_head
697
740
  });
698
741
  }
699
742
  const totalLines=retainedRanges.length;
@@ -705,7 +748,9 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
705
748
  readFrom,readCount,totalLines,remaining,lines,state:snapshot.state,exitCode:snapshot.exit_code,truncated:snapshot.earliest_offset>0
706
749
  }),false,{
707
750
  pid:args.pid,session_id:sessionId,read_from:readFrom,read_count:readCount,total_lines:totalLines,
708
- remaining,next_line:readFrom+readCount,stream_head:snapshot.stream_head,state:snapshot.state,exit_code:snapshot.exit_code
751
+ remaining,next_line:readFrom+readCount,stream_head:snapshot.stream_head,state:snapshot.state,exit_code:snapshot.exit_code,
752
+ earliest_offset:snapshot.earliest_offset,truncated:snapshot.earliest_offset>0,
753
+ next_byte_offset:null,more_unread:remaining>0
709
754
  });
710
755
  }
711
756
  async function interactProcess(args){
@@ -745,8 +790,15 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
745
790
  return text(`Successfully terminated process ${args.pid}`,false,{pid:args.pid,session_id:sessionId,...native});
746
791
  }
747
792
  async function listSessions(){
793
+ if(!processRuntime)throw new Error('process runtime is disabled');
748
794
  const native=await processRuntime.list();
749
- return text(native.sessions.map(s=>`Session ${s.session_id}: state=${s.state}, profile=${s.profile_id}, root=${s.root_id}`).join('\\n'),false,{sessions:native.sessions});
795
+ if(!Array.isArray(native?.sessions))throw new Error('native session inventory unavailable');
796
+ const rows=native.sessions.map(s=>{
797
+ const pid=Number.isInteger(s.child_pid)&&s.child_pid>0?String(s.child_pid):'unknown';
798
+ const exit=s.state==='running'?'':`, exit=${Number.isInteger(s.exit_code)?s.exit_code:'unknown'}`;
799
+ return `Session ${s.session_id}: PID=${pid}, state=${s.state}, profile=${s.profile_id}, root=${s.root_id}${exit}`;
800
+ });
801
+ return text(rows.length?rows.join('\n'):'No managed sessions.',false,{sessions:native.sessions});
750
802
  }
751
803
  async function listProcesses(){
752
804
  if(!externalProcessRuntime) return listSessions();
@@ -882,11 +934,10 @@ export function createCommanderWireAdapter({machineFsRuntime=null,processRuntime
882
934
  async function getMoreSearchResults(args){
883
935
  const s=searchSessions.get(args.sessionId);
884
936
  if(!s) throw new Error('search session not found');
885
- if(!s.isComplete) return {results:[],returnedCount:0,totalResults:0,isComplete:false,isError:false,hasMoreResults:true};
886
937
  const start=args.offset<0?Math.max(0,s.results.length+args.offset):args.offset;
887
938
  const end=args.offset<0?s.results.length:Math.min(s.results.length,start+args.length);
888
939
  const results=s.results.slice(start,end);
889
- return {results,returnedCount:results.length,totalResults:s.results.length,isComplete:true,isError:s.isError,error:s.error,hasMoreResults:end<s.results.length,runtime:Date.now()-s.created};
940
+ return {results,returnedCount:results.length,totalResults:s.results.length,isComplete:s.isComplete,isError:s.isError,error:s.error,hasMoreResults:!s.isComplete||end<s.results.length,runtime:Date.now()-s.created};
890
941
  }
891
942
  async function stopSearch(args){
892
943
  const s=searchSessions.get(args.sessionId);
@@ -400,7 +400,11 @@ async function cleanupRemoteSession(ctx,{displayMode='unicode'}={}){
400
400
  for(const command of SESSION_CLEANUP){
401
401
  try{
402
402
  const result=invokeController(ctx,command,{allowFailure:true,capture:true,echoCaptured:false,timeoutMs:REMOTE_STATUS_TIMEOUT_MS});
403
- if(result.status!==0){failed.push(command);writeRemote(process.stderr,`${remoteTag('warn',displayMode)} cleanup ${command} exit=${result.status}\n`,displayMode);}
403
+ if(result.status!==0){
404
+ failed.push(command);
405
+ const detail=boundedSingleLine(result.stderr||result.stdout||'controller returned no diagnostic',512);
406
+ writeRemote(process.stderr,`${remoteTag('warn',displayMode)} cleanup ${command} exit=${result.status}: ${detail}\n`,displayMode);
407
+ }
404
408
  }
405
409
  catch(error){failed.push(command);writeRemote(process.stderr,`${remoteTag('warn',displayMode)} cleanup ${command}: ${String(error?.message||error)}\n`,displayMode);}
406
410
  }
@@ -0,0 +1,77 @@
1
+ import {fork} from 'node:child_process';
2
+ import {fileURLToPath} from 'node:url';
3
+ import path from 'node:path';
4
+
5
+ const worker=fileURLToPath(new URL('./scoped-observer-worker-r136.mjs',import.meta.url));
6
+ const hex=/^[0-9a-f]{64}$/;
7
+ export async function startScopedObserver({stateRoot,codingPolicyFile,autonomousPolicyFile,codingSha256,autonomousSha256,ambientEnv={}}={}){
8
+ if(![stateRoot,codingPolicyFile,autonomousPolicyFile].every(p=>typeof p==='string'&&path.isAbsolute(p)))
9
+ throw new Error('observer absolute isolated paths required');
10
+ if(!hex.test(codingSha256??'')||!hex.test(autonomousSha256??''))
11
+ throw new Error('observer pinned policy SHA256 required');
12
+ // Deliberately no DEADBYTE_ authority config, inherited token, NODE_OPTIONS,
13
+ // or shell PATH. Child is a disarmed status-only IPC process, not an OS sandbox.
14
+ const env={};
15
+ for(const key of ['SystemRoot','SYSTEMROOT','windir','WINDIR','TEMP','TMP']){
16
+ if(typeof process.env[key]==='string')env[key]=process.env[key];
17
+ }
18
+ // ambientEnv is an adversarial test vector and is NEVER merged.
19
+ void ambientEnv;
20
+ const child=fork(worker,[stateRoot,codingPolicyFile,codingSha256,autonomousPolicyFile,autonomousSha256],{
21
+ execPath:process.execPath,execArgv:[],cwd:stateRoot,env,stdio:['ignore','ignore','ignore','ipc'],windowsHide:true
22
+ });
23
+ let pending=new Map(),serial=0,closed=false;
24
+ const rejectAll=reason=>{for(const {reject,timer} of pending.values()){clearTimeout(timer);reject(reason);}pending.clear();};
25
+ let bootResolve,bootReject;
26
+ const boot=new Promise((resolve,reject)=>{bootResolve=resolve;bootReject=reject});
27
+ let bootFinished=false;
28
+ const bootTimer=setTimeout(()=>{if(!bootFinished){bootFinished=true;bootReject(new Error('observer boot failed: timeout'));child.kill();}},6000);
29
+ child.on('message',msg=>{
30
+ if(msg?.kind==='ready'&&!bootFinished){bootFinished=true;clearTimeout(bootTimer);bootResolve(msg.result);return;}
31
+ if(msg?.kind==='boot-error'&&!bootFinished){bootFinished=true;clearTimeout(bootTimer);bootReject(new Error('observer boot failed: '+msg.reason));child.kill();return;}
32
+ if(msg?.kind==='reply'){
33
+ const p=pending.get(msg.id);if(!p)return;pending.delete(msg.id);clearTimeout(p.timer);p.resolve(msg.result);
34
+ }
35
+ });
36
+ child.on('error',error=>{if(!bootFinished){bootFinished=true;clearTimeout(bootTimer);bootReject(error);}rejectAll(error);});
37
+ child.on('exit',(code)=>{
38
+ closed=true;
39
+ if(!bootFinished){bootFinished=true;clearTimeout(bootTimer);bootReject(new Error('observer boot failed: exit '+code));}
40
+ rejectAll(new Error('observer exited'));
41
+ });
42
+ let ready;
43
+ try{ready=await boot;}catch(error){
44
+ if(!closed){
45
+ const exited=new Promise(resolve=>{
46
+ const timer=setTimeout(()=>{child.kill();},1500);
47
+ child.once('exit',()=>{clearTimeout(timer);resolve();});
48
+ });
49
+ child.kill();await exited;
50
+ }
51
+ throw error;
52
+ }
53
+ const request=payload=>{
54
+ if(closed) return Promise.reject(new Error('observer already exited'));
55
+ return new Promise((resolve,reject)=>{
56
+ const id=++serial;
57
+ const timer=setTimeout(()=>{pending.delete(id);reject(new Error('observer request timeout'));child.kill();},3000);
58
+ pending.set(id,{resolve,reject,timer});
59
+ try{child.send({kind:'request',id,op:payload?.op},err=>{if(err){clearTimeout(timer);pending.delete(id);reject(err);}});}
60
+ catch(error){clearTimeout(timer);pending.delete(id);reject(error);}
61
+ });
62
+ };
63
+ return Object.freeze({
64
+ ready,
65
+ request,
66
+ async close(){
67
+ if(closed)return;
68
+ const exited=new Promise(resolve=>{
69
+ if(closed)return resolve();
70
+ const timer=setTimeout(()=>{child.kill();},1500);
71
+ child.once('exit',()=>{clearTimeout(timer);resolve();});
72
+ });
73
+ try{await request({op:'shutdown'});}catch{child.kill();}
74
+ await exited;
75
+ }
76
+ });
77
+ }