@kontextmind/kxm 0.6.0 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/agents/coordinator.yaml +9 -0
- package/.kxm/agents/critic-arch.yaml +13 -0
- package/.kxm/agents/critic-cli.yaml +13 -0
- package/.kxm/agents/implementer.yaml +13 -0
- package/.kxm/gates.yaml +8 -0
- package/.kxm/producers.yaml +22 -0
- package/.kxm/project.yaml +15 -0
- package/.kxm/roles/writer.yaml +7 -0
- package/.kxm/workflows/default.yaml +47 -0
- package/CHANGELOG.md +39 -7
- package/README.md +1 -0
- package/docs/README.md +5 -0
- package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
- package/docs/agent-skills.md +135 -0
- package/docs/architecture.md +1 -1
- package/docs/assignment-runner.md +21 -8
- package/docs/browser-automation.md +116 -0
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +21 -0
- package/docs/kb/how-credentials-retrieved-safely.md +31 -0
- package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
- package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
- package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
- package/docs/kb/how-to-resume-after-mfa.md +28 -0
- package/docs/kb/how-to-take-over-session.md +32 -0
- package/docs/kb/why-authentication-disappeared.md +32 -0
- package/docs/kb/why-automation-opened-different-browser.md +32 -0
- package/docs/kb/why-session-viewer-cannot-control.md +31 -0
- package/docs/kxm-handbook.md +3 -3
- package/docs/operations.md +24 -0
- package/docs/operator-pi-packages.md +63 -0
- package/docs/prompts/browser-annotate-feedback.md +41 -0
- package/docs/prompts/browser-diagnose-recover.md +38 -0
- package/docs/prompts/browser-explore.md +42 -0
- package/docs/prompts/browser-repro-fix.md +48 -0
- package/docs/prompts/browser-start.md +41 -0
- package/docs/prompts/browser-takeover.md +50 -0
- package/docs/skills/repo-work-delivery.md +107 -0
- package/docs/skills.md +2 -0
- package/docs/test-matrix.md +4 -3
- package/docs/troubleshooting.md +41 -1
- package/docs/vnext/validation.md +9 -0
- package/docs/webhook-workflows.md +2 -2
- package/examples/README.md +1 -1
- package/package.json +16 -17
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +1 -1
- package/plugins/kxm/dist/cli.js +41620 -35578
- package/plugins/kxm/dist/core.js +271 -34
- package/plugins/kxm/dist/extension.js +7759 -86
- package/plugins/kxm/dist/mcp-server.js +75 -21
- package/plugins/kxm/dist/runtime.js +8218 -2328
- package/plugins/kxm/dist/server.js +3125 -2260
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +5961 -661
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/SUITE.md +5 -0
- package/plugins/kxm/skills/hints.json +103 -0
- package/plugins/kxm/skills/kxm/SKILL.md +30 -83
- package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
- package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
- package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
- package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
- package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
- package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
- package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
- package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
- package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
- package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
- package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
- package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
- package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
- package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
- package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
- package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
- package/plugins/kxm/src/autocomplete.ts +9 -3
- package/plugins/kxm/src/browser.ts +603 -0
- package/plugins/kxm/src/cli/context-skills.ts +373 -0
- package/plugins/kxm/src/cli/hub.ts +614 -0
- package/plugins/kxm/src/cli/roles.ts +615 -0
- package/plugins/kxm/src/cli/system.ts +906 -0
- package/plugins/kxm/src/cli/tasks.ts +364 -0
- package/plugins/kxm/src/cli/types.ts +270 -0
- package/plugins/kxm/src/cli/vnext.ts +698 -0
- package/plugins/kxm/src/cli/workflows.ts +699 -0
- package/plugins/kxm/src/cli.ts +362 -2849
- package/plugins/kxm/src/commands.ts +150 -8
- package/plugins/kxm/src/completion-install.ts +223 -0
- package/plugins/kxm/src/config.ts +7 -4
- package/plugins/kxm/src/context-packet.ts +172 -0
- package/plugins/kxm/src/database.ts +1 -1
- package/plugins/kxm/src/extension.ts +36 -1
- package/plugins/kxm/src/external-effects.ts +357 -8
- package/plugins/kxm/src/hub-env.ts +193 -0
- package/plugins/kxm/src/hub.ts +2 -4
- package/plugins/kxm/src/improve.ts +72 -0
- package/plugins/kxm/src/init-guide-setup.ts +547 -0
- package/plugins/kxm/src/local-snapshot.ts +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/model-inventory.ts +127 -0
- package/plugins/kxm/src/modes.ts +348 -0
- package/plugins/kxm/src/policy-draft.d.mts +55 -0
- package/plugins/kxm/src/policy-draft.mjs +565 -0
- package/plugins/kxm/src/price-calc.ts +17 -18
- package/plugins/kxm/src/prices.ts +32 -16
- package/plugins/kxm/src/producers.ts +71 -0
- package/plugins/kxm/src/protocol.ts +111 -0
- package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
- package/plugins/kxm/src/restricted-yaml.mjs +145 -0
- package/plugins/kxm/src/role.ts +710 -0
- package/plugins/kxm/src/routing.ts +99 -1
- package/plugins/kxm/src/runtime.ts +4 -0
- package/plugins/kxm/src/safety-integrity.ts +76 -0
- package/plugins/kxm/src/session-work.ts +9 -2
- package/plugins/kxm/src/sqlite.ts +76 -0
- package/plugins/kxm/src/ssh-remote.ts +560 -0
- package/plugins/kxm/src/store.ts +1 -1
- package/plugins/kxm/src/studio-layout.ts +660 -17
- package/plugins/kxm/src/subagent-control.ts +312 -0
- package/plugins/kxm/src/suggest.ts +7 -13
- package/plugins/kxm/src/telemetry.ts +82 -0
- package/plugins/kxm/src/tui.ts +140 -0
- package/plugins/kxm/src/vnext-bindings.ts +1 -1
- package/plugins/kxm/src/vnext-config.ts +53 -111
- package/plugins/kxm/src/vnext-engine-command.ts +2 -0
- package/plugins/kxm/src/vnext-engine.ts +214 -62
- package/plugins/kxm/src/vnext-harness.ts +336 -84
- package/plugins/kxm/src/vnext-oneshot-evidence.ts +117 -0
- package/plugins/kxm/src/vnext-oneshot-process.ts +187 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +182 -224
- package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
- package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +122 -5
- package/plugins/kxm/src/vnext-runtime.ts +14 -0
- package/plugins/kxm/src/workflow-manager.ts +392 -0
- package/plugins/kxm/src/workflow-tui.ts +255 -0
- package/plugins/kxm/src/workflow.ts +144 -0
- package/schemas/policy-draft/README.md +17 -0
- package/schemas/policy-draft/model.v2.schema.json +140 -0
- package/schemas/policy-draft/role.v2.schema.json +91 -0
- package/schemas/vnext/modes.schema.json +56 -0
- package/schemas/vnext/role.schema.json +76 -0
- package/schemas/vnext/run-event.schema.json +1 -0
- package/scripts/assignment-run.d.mts +1 -1
- package/scripts/assignment-run.mjs +44 -35
- package/scripts/check-generated.mjs +33 -9
- package/scripts/emit-codex-artifacts.mjs +255 -11
- package/scripts/harness-run.d.mts +12 -4
- package/scripts/harness-run.mjs +65 -17
- package/scripts/kxm-bump-version.mjs +146 -0
- package/scripts/kxm-hub.mjs +150 -2
- package/scripts/kxm-publish-npm.mjs +3 -1
- package/scripts/kxm-release-github.mjs +3 -1
- package/scripts/kxm.mjs +0 -0
- package/scripts/native-critic.d.mts +5 -0
- package/scripts/native-critic.mjs +60 -0
- package/.kxm/config/README.md +0 -5
- package/.kxm/config/agents.json +0 -43
- package/.kxm/config/env.example +0 -56
- package/.kxm/config/update.example.yaml +0 -9
- package/.kxm/config/workflows/fix.json +0 -160
- package/.kxm/config/workflows/jira-development.json +0 -116
- package/.kxm/config/workflows/provenance-quorum.json +0 -150
- package/.kxm/config/workflows/v04-dogfood.json +0 -72
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, rename } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { VnextOneShotProcessResult } from "./vnext-oneshot-process.ts";
|
|
5
|
+
|
|
6
|
+
export const ONESHOT_EVIDENCE_SCHEMA = "kxm.oneshot-evidence.v2" as const;
|
|
7
|
+
const RETIRED_ONESHOT_EVIDENCE_SCHEMAS = new Set(["kxm.oneshot-evidence.v1"]);
|
|
8
|
+
|
|
9
|
+
const TEXT_LIMIT = 4 * 1024 * 1024;
|
|
10
|
+
const ARGV_LIMIT = 64 * 1024;
|
|
11
|
+
const RECORD_LIMIT = 16 * 1024 * 1024;
|
|
12
|
+
const digest = (value: string): string => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
13
|
+
|
|
14
|
+
export interface OneShotEvidenceRecord {
|
|
15
|
+
schema: typeof ONESHOT_EVIDENCE_SCHEMA;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Brake: retired and unknown persisted schema ids are never aliased or upgraded. */
|
|
20
|
+
export function parseOneShotEvidenceRecord(bytes: string): OneShotEvidenceRecord {
|
|
21
|
+
let parsed: unknown;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(bytes) as unknown;
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error("oneshot_evidence_record_invalid");
|
|
26
|
+
}
|
|
27
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
28
|
+
throw new Error("oneshot_evidence_record_invalid");
|
|
29
|
+
}
|
|
30
|
+
const schema = (parsed as { schema?: unknown }).schema;
|
|
31
|
+
if (typeof schema !== "string") throw new Error("oneshot_evidence_record_invalid");
|
|
32
|
+
if (RETIRED_ONESHOT_EVIDENCE_SCHEMAS.has(schema)) throw new Error("oneshot_evidence_schema_retired");
|
|
33
|
+
if (schema !== ONESHOT_EVIDENCE_SCHEMA) throw new Error("oneshot_evidence_schema_unknown");
|
|
34
|
+
return parsed as OneShotEvidenceRecord;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Private diagnostic evidence, never a peer reply or acceptance authority.
|
|
38
|
+
* Retained until operator archival; no automatic deletion of failed attempts.
|
|
39
|
+
* Each record is bounded; inability to reserve/write evidence fails closed.
|
|
40
|
+
*/
|
|
41
|
+
export async function beginOneShotEvidence(root: string, intent: {
|
|
42
|
+
runId: string; stepId: string; attemptId: string; assignmentId: string;
|
|
43
|
+
harness: string; provider: string; model: string; cwd: string;
|
|
44
|
+
command: string; args: readonly string[]; input?: string | undefined;
|
|
45
|
+
}, sensitive: readonly string[]): Promise<{
|
|
46
|
+
id: string;
|
|
47
|
+
finish(result: VnextOneShotProcessResult, observation: unknown): Promise<string>;
|
|
48
|
+
}> {
|
|
49
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
50
|
+
const stat = await lstat(root);
|
|
51
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()
|
|
52
|
+
|| (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) {
|
|
53
|
+
throw new Error("oneshot_evidence_root_not_private");
|
|
54
|
+
}
|
|
55
|
+
const id = `evd_${randomUUID().replaceAll("-", "")}`;
|
|
56
|
+
const dir = join(root, id);
|
|
57
|
+
await mkdir(dir, { mode: 0o700 });
|
|
58
|
+
const secrets = [...new Set(sensitive.filter((value) => value.length >= 8))].sort((a, b) => b.length - a.length);
|
|
59
|
+
const redact = (value: string): string => {
|
|
60
|
+
let text = value;
|
|
61
|
+
for (const secret of secrets) text = text.replaceAll(secret, "[REDACTED]");
|
|
62
|
+
return text
|
|
63
|
+
.replace(/(\b(?:authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret)\b["']?\s*[:=]\s*["']?)(?:Bearer\s+)?[^\s"',;}]+/gi, "$1[REDACTED]")
|
|
64
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
65
|
+
};
|
|
66
|
+
const bounded = (value: string, max = TEXT_LIMIT): { text: string; truncated: boolean; sha256: string } => {
|
|
67
|
+
const bytes = Buffer.from(redact(value));
|
|
68
|
+
return { text: bytes.subarray(0, max).toString("utf8"), truncated: bytes.length > max, sha256: digest(redact(value)) };
|
|
69
|
+
};
|
|
70
|
+
const write = async (name: string, value: unknown): Promise<string> => {
|
|
71
|
+
const current = await lstat(dir);
|
|
72
|
+
if (!current.isDirectory() || current.isSymbolicLink()
|
|
73
|
+
|| (process.platform !== "win32" && ((current.mode & 0o077) !== 0 || current.uid !== process.getuid?.()))) {
|
|
74
|
+
throw new Error("oneshot_evidence_directory_changed");
|
|
75
|
+
}
|
|
76
|
+
const bytes = JSON.stringify(value);
|
|
77
|
+
if (Buffer.byteLength(bytes) > RECORD_LIMIT) throw new Error("oneshot_evidence_record_limit");
|
|
78
|
+
parseOneShotEvidenceRecord(bytes);
|
|
79
|
+
const target = join(dir, name);
|
|
80
|
+
const partial = `${target}.partial`;
|
|
81
|
+
const file = await open(partial, "wx", 0o600);
|
|
82
|
+
try { await file.writeFile(bytes); await file.sync(); }
|
|
83
|
+
finally { await file.close(); }
|
|
84
|
+
await rename(partial, target);
|
|
85
|
+
if (process.platform !== "win32") {
|
|
86
|
+
const directory = await open(dir, "r");
|
|
87
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
88
|
+
}
|
|
89
|
+
return digest(bytes);
|
|
90
|
+
};
|
|
91
|
+
await write("intent.json", {
|
|
92
|
+
schema: ONESHOT_EVIDENCE_SCHEMA, id, recordedAt: new Date().toISOString(),
|
|
93
|
+
runId: intent.runId, stepId: intent.stepId, attemptId: intent.attemptId, assignmentId: intent.assignmentId,
|
|
94
|
+
harness: intent.harness, provider: intent.provider, requestedModel: intent.model,
|
|
95
|
+
cwd: intent.cwd, command: redact(intent.command),
|
|
96
|
+
argv: bounded(JSON.stringify(intent.args), ARGV_LIMIT), stdin: intent.input === undefined ? null : bounded(intent.input),
|
|
97
|
+
env: null,
|
|
98
|
+
acceptance: false,
|
|
99
|
+
});
|
|
100
|
+
let finished = false;
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
async finish(result, observation) {
|
|
104
|
+
if (finished) throw new Error("oneshot_evidence_already_finished");
|
|
105
|
+
finished = true;
|
|
106
|
+
return write("result.json", {
|
|
107
|
+
schema: ONESHOT_EVIDENCE_SCHEMA, id, recordedAt: new Date().toISOString(),
|
|
108
|
+
stdout: bounded(result.stdout), stderr: bounded(result.stderr),
|
|
109
|
+
code: result.code, signal: result.signal ?? null, started: result.started ?? null,
|
|
110
|
+
observedChildExit: result.observedChildExit ?? null, terminationRequested: result.terminationRequested ?? null,
|
|
111
|
+
unverifiedDescendants: result.unverifiedDescendants ?? null,
|
|
112
|
+
error: result.error ? bounded(result.error.message) : null,
|
|
113
|
+
observation: bounded(JSON.stringify(observation)), acceptance: false,
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface VnextOneShotProcessResult {
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
code: number | null;
|
|
7
|
+
signal?: NodeJS.Signals | null | undefined;
|
|
8
|
+
observedChildExit?: boolean | undefined;
|
|
9
|
+
started?: boolean | undefined;
|
|
10
|
+
/** Stop was requested; direct-child reaping cannot attest escaped descendants. */
|
|
11
|
+
terminationRequested?: boolean | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Direct-child close/reap is not descendant death. Unobserved process-group
|
|
14
|
+
* members stay unverified: never success, never a forged descendant failure.
|
|
15
|
+
*/
|
|
16
|
+
unverifiedDescendants?: boolean | undefined;
|
|
17
|
+
error?: Error | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VnextOneShotSpawnOptions {
|
|
21
|
+
cwd?: string | undefined;
|
|
22
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
23
|
+
input?: string | undefined;
|
|
24
|
+
timeoutMs?: number | undefined;
|
|
25
|
+
signal?: AbortSignal | undefined;
|
|
26
|
+
/** Only allowlisted Windows `.cmd` probes may set this; never a user string. */
|
|
27
|
+
shell?: boolean | undefined;
|
|
28
|
+
}
|
|
29
|
+
export type VnextOneShotSpawn = (command: string, args: readonly string[], options: VnextOneShotSpawnOptions) => Promise<VnextOneShotProcessResult>;
|
|
30
|
+
|
|
31
|
+
const OUTPUT_LIMIT = 8 * 1024 * 1024;
|
|
32
|
+
const KILL_GRACE_MS = 250;
|
|
33
|
+
const DRAIN_GRACE_MS = 500;
|
|
34
|
+
const REAP_GRACE_MS = 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* POSIX process-group tree kill with fallback to direct child kill.
|
|
38
|
+
* Ensures orphaned subshells, test workers, and background daemons are pruned on cancellation/timeout.
|
|
39
|
+
*/
|
|
40
|
+
export function killProcessTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals = "SIGKILL"): void {
|
|
41
|
+
if (process.platform !== "win32" && child.pid) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(-child.pid, signal);
|
|
44
|
+
return;
|
|
45
|
+
} catch {
|
|
46
|
+
// Fall through to direct child kill if process group is unavailable
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
child.kill(signal);
|
|
51
|
+
} catch {
|
|
52
|
+
// Direct kill error if child already exited
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Bound both execution and pipe drain. Observing exit is not proof of descendant death. */
|
|
57
|
+
export function defaultSpawn(command: string, args: readonly string[], options: VnextOneShotSpawnOptions): Promise<VnextOneShotProcessResult> {
|
|
58
|
+
if (options.signal?.aborted) {
|
|
59
|
+
return Promise.resolve({ stdout: "", stderr: "", code: null, started: false, observedChildExit: false, error: new Error("process_aborted") });
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const stdout: Buffer[] = [];
|
|
63
|
+
const stderr: Buffer[] = [];
|
|
64
|
+
let outputBytes = 0;
|
|
65
|
+
let outputChunks = 0;
|
|
66
|
+
let code: number | null = null;
|
|
67
|
+
let signal: NodeJS.Signals | null = null;
|
|
68
|
+
let observedChildExit = false;
|
|
69
|
+
let finished = false;
|
|
70
|
+
let stopping = false;
|
|
71
|
+
let closed = false;
|
|
72
|
+
let killSent = false;
|
|
73
|
+
let error: Error | undefined;
|
|
74
|
+
let wallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
let reapTimer: ReturnType<typeof setTimeout> | undefined;
|
|
78
|
+
let child: ReturnType<typeof spawn>;
|
|
79
|
+
const finish = (): void => {
|
|
80
|
+
if (finished) return;
|
|
81
|
+
// Direct-child close must not cancel SIGTERM → SIGKILL. Refuse to settle
|
|
82
|
+
// until the group kill has been sent, then reap on the existing timers.
|
|
83
|
+
if (stopping && !killSent) return;
|
|
84
|
+
finished = true;
|
|
85
|
+
for (const timer of [wallTimer, killTimer, drainTimer, reapTimer]) clearTimeout(timer);
|
|
86
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
87
|
+
child?.stdin?.destroy();
|
|
88
|
+
child?.stdout?.destroy();
|
|
89
|
+
child?.stderr?.destroy();
|
|
90
|
+
child?.unref();
|
|
91
|
+
const started = Boolean(child?.pid);
|
|
92
|
+
if (started && !observedChildExit) error ??= new Error("process_exit_unobserved");
|
|
93
|
+
const unverifiedDescendants = stopping || (started && !observedChildExit);
|
|
94
|
+
resolve({
|
|
95
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
96
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
97
|
+
code,
|
|
98
|
+
signal,
|
|
99
|
+
started,
|
|
100
|
+
observedChildExit,
|
|
101
|
+
terminationRequested: stopping,
|
|
102
|
+
...(unverifiedDescendants ? { unverifiedDescendants: true } : {}),
|
|
103
|
+
...(error ? { error } : {}),
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const kill = (requested: NodeJS.Signals): void => {
|
|
107
|
+
killProcessTree(child, requested);
|
|
108
|
+
};
|
|
109
|
+
const stop = (reason: string): void => {
|
|
110
|
+
if (finished) return;
|
|
111
|
+
error ??= new Error(reason);
|
|
112
|
+
if (stopping) return;
|
|
113
|
+
stopping = true;
|
|
114
|
+
clearTimeout(wallTimer);
|
|
115
|
+
kill("SIGTERM");
|
|
116
|
+
killTimer = setTimeout(() => {
|
|
117
|
+
kill("SIGKILL");
|
|
118
|
+
killSent = true;
|
|
119
|
+
if (closed) { finish(); return; }
|
|
120
|
+
reapTimer = setTimeout(() => {
|
|
121
|
+
if (!observedChildExit) error ??= new Error("process_exit_unobserved");
|
|
122
|
+
finish();
|
|
123
|
+
}, REAP_GRACE_MS);
|
|
124
|
+
}, KILL_GRACE_MS);
|
|
125
|
+
};
|
|
126
|
+
const onAbort = (): void => stop("process_aborted");
|
|
127
|
+
try {
|
|
128
|
+
child = spawn(command, [...args], {
|
|
129
|
+
cwd: options.cwd,
|
|
130
|
+
env: options.env ?? process.env,
|
|
131
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
132
|
+
shell: options.shell === true,
|
|
133
|
+
windowsHide: true,
|
|
134
|
+
detached: process.platform !== "win32",
|
|
135
|
+
});
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
error = cause instanceof Error ? cause : new Error("process_spawn_failed");
|
|
138
|
+
finish();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const collect = (target: Buffer[], chunk: Buffer | string): void => {
|
|
142
|
+
if (finished) return;
|
|
143
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
144
|
+
const remaining = Math.max(0, OUTPUT_LIMIT - outputBytes);
|
|
145
|
+
if (remaining && outputChunks < 16384) {
|
|
146
|
+
const kept = bytes.subarray(0, remaining);
|
|
147
|
+
target.push(kept);
|
|
148
|
+
outputBytes += kept.length;
|
|
149
|
+
outputChunks++;
|
|
150
|
+
}
|
|
151
|
+
if (bytes.length > remaining || outputChunks >= 16384) stop("process_output_limit");
|
|
152
|
+
};
|
|
153
|
+
child.stdout!.on("data", (chunk: Buffer) => collect(stdout, chunk));
|
|
154
|
+
child.stderr!.on("data", (chunk: Buffer) => collect(stderr, chunk));
|
|
155
|
+
child.stdin!.on("error", () => stop("process_stdin_error"));
|
|
156
|
+
child.stdout!.on("error", () => stop("process_stdio_error"));
|
|
157
|
+
child.stderr!.on("error", () => stop("process_stdio_error"));
|
|
158
|
+
child.on("error", (cause) => {
|
|
159
|
+
error ??= cause;
|
|
160
|
+
if (!child.pid) finish();
|
|
161
|
+
else stop("process_error");
|
|
162
|
+
});
|
|
163
|
+
child.on("exit", (exitCode, exitSignal) => {
|
|
164
|
+
if (finished) return;
|
|
165
|
+
observedChildExit = true;
|
|
166
|
+
code = exitCode;
|
|
167
|
+
signal = exitSignal;
|
|
168
|
+
clearTimeout(wallTimer);
|
|
169
|
+
drainTimer = setTimeout(() => stop("process_stdio_unclosed"), DRAIN_GRACE_MS);
|
|
170
|
+
});
|
|
171
|
+
child.on("close", (exitCode, exitSignal) => {
|
|
172
|
+
if (finished) return;
|
|
173
|
+
closed = true;
|
|
174
|
+
code = exitCode;
|
|
175
|
+
signal = exitSignal;
|
|
176
|
+
// The leader can exit and close its pipes while a descendant ignores TERM.
|
|
177
|
+
// finish() no-ops until SIGKILL when escalation is in flight.
|
|
178
|
+
finish();
|
|
179
|
+
});
|
|
180
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
181
|
+
if (options.signal?.aborted) onAbort();
|
|
182
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
183
|
+
if (!stopping) wallTimer = setTimeout(() => stop("process_timeout"), timeoutMs > 0 && Number.isFinite(timeoutMs) ? timeoutMs : 120_000);
|
|
184
|
+
try { child.stdin!.end(options.input); }
|
|
185
|
+
catch { stop("process_stdin_error"); }
|
|
186
|
+
});
|
|
187
|
+
}
|