@yagni-app/code-staging 0.3.0-staging.1081.1 → 0.3.0-staging.1085.1

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Termination-signal forwarding: launcher → pi child.
3
+ *
4
+ * In the interactive TUI the tty is in raw mode, so Ctrl+C never raises
5
+ * SIGINT; this path covers `yagni -p`/piped/CI runs and an external
6
+ * `kill <launcher pid>`. Policy:
7
+ *
8
+ * - The FIRST signal asks pi to shut down cleanly. SIGINT is translated to
9
+ * SIGTERM because pi installs a graceful handler for SIGTERM (it kills its
10
+ * tracked children) but none for SIGINT — a raw SIGINT would drop pi on the
11
+ * default disposition with no cleanup.
12
+ * - Any SECOND signal hard-kills pi's whole process TREE. A bare SIGKILL to
13
+ * pi's pid would bypass its exit sweep (SIGKILL runs no handlers) and leave
14
+ * /go workers alive, so the launcher enumerates and kills the descendants
15
+ * itself: `ps` walk on POSIX, `taskkill /T /F` on Windows.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { constants as osConstants } from "node:os";
19
+ /** The launcher-terminating signals that are forwarded rather than obeyed. */
20
+ export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
21
+ /**
22
+ * PURE policy for the FIRST signal: which signal the child receives when the
23
+ * launcher gets `received` (SIGINT is translated, the rest pass through).
24
+ * Escalation is not expressed here — a second signal goes through the tree
25
+ * kill, not a forwarded signal.
26
+ */
27
+ export function forwardedSignal(received) {
28
+ return received === "SIGINT" ? "SIGTERM" : received;
29
+ }
30
+ /**
31
+ * PURE: the launcher's own exit code for the child's (code, signal) exit tuple.
32
+ * A signal-terminated child maps to the conventional 128+n (bash parity:
33
+ * SIGTERM→143, SIGKILL→137, SIGINT→130), so a cancelled run never reads as
34
+ * success to scripts or CI.
35
+ */
36
+ export function exitCodeFor(code, signal) {
37
+ if (code != null)
38
+ return code;
39
+ if (signal)
40
+ return 128 + (osConstants.signals[signal] ?? 15);
41
+ return 0;
42
+ }
43
+ /**
44
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
45
+ * the given roots (children, grandchildren, ...), breadth-first. Mirrored in
46
+ * pi-extension-yagni's childRegistry (the packages are intentionally
47
+ * independent); keep the two in sync.
48
+ */
49
+ export function descendantsOf(roots, psLines) {
50
+ const childrenByParent = new Map();
51
+ for (const line of psLines) {
52
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
53
+ if (!m)
54
+ continue;
55
+ const pid = Number(m[1]);
56
+ const ppid = Number(m[2]);
57
+ const list = childrenByParent.get(ppid) ?? [];
58
+ list.push(pid);
59
+ childrenByParent.set(ppid, list);
60
+ }
61
+ const found = [];
62
+ const queue = [...roots];
63
+ while (queue.length > 0) {
64
+ const next = queue.shift();
65
+ for (const child of childrenByParent.get(next) ?? []) {
66
+ found.push(child);
67
+ queue.push(child);
68
+ }
69
+ }
70
+ return found;
71
+ }
72
+ /**
73
+ * Synchronously SIGKILL a process AND its descendants (POSIX `ps` walk /
74
+ * Windows `taskkill /T /F`). Throw-proof; fail-soft to a direct kill when no
75
+ * process snapshot is readable.
76
+ */
77
+ export function killTreeSync(pid) {
78
+ if (process.platform === "win32") {
79
+ try {
80
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
81
+ }
82
+ catch {
83
+ /* best effort */
84
+ }
85
+ return;
86
+ }
87
+ let descendants = [];
88
+ try {
89
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
90
+ if (ps.status === 0 && typeof ps.stdout === "string") {
91
+ descendants = descendantsOf([pid], ps.stdout.split("\n"));
92
+ }
93
+ }
94
+ catch {
95
+ /* no snapshot: fall through to the direct kill */
96
+ }
97
+ for (const target of [pid, ...descendants]) {
98
+ try {
99
+ process.kill(target, "SIGKILL");
100
+ }
101
+ catch {
102
+ /* already dead */
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Subscribe the forwarding policy for every signal in {@link FORWARDED_SIGNALS}.
108
+ * `subscribe` and `killTree` default to the real process surfaces and are
109
+ * injectable so tests never install real handlers, raise real signals, or kill
110
+ * real processes. Returns the forward function itself (also for tests).
111
+ * pid guard: a child whose spawn failed has no pid, and `kill()` would resolve
112
+ * that to the whole process group.
113
+ */
114
+ export function installSignalForwarding(child, subscribe = (sig, handler) => process.on(sig, handler), killTree = killTreeSync) {
115
+ let received = 0;
116
+ const forward = (sig) => {
117
+ const prior = received;
118
+ received += 1;
119
+ if (!child.pid)
120
+ return;
121
+ if (prior > 0)
122
+ killTree(child.pid);
123
+ else
124
+ child.kill(forwardedSignal(sig));
125
+ };
126
+ for (const sig of FORWARDED_SIGNALS)
127
+ subscribe(sig, () => forward(sig));
128
+ return forward;
129
+ }
130
+ //# sourceMappingURL=signalForward.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1081.1",
3
+ "version": "0.3.0-staging.1085.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "dd068fdb7b8bcb1207ff4f2fe803bf26b2e3eec3"
41
+ "yagniSourceSha": "2af1762b42b06fc1e55b7f85fbbed71ce3bd3272"
42
42
  }