@llblab/pi-actors 0.20.2 → 0.22.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.
Files changed (41) hide show
  1. package/BACKLOG.md +34 -80
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +7 -1
  4. package/dist/index.js +11 -0
  5. package/dist/lib/actor-rooms.d.ts +1 -0
  6. package/dist/lib/actor-rooms.js +33 -1
  7. package/dist/lib/async-runs.d.ts +35 -1
  8. package/dist/lib/async-runs.js +318 -36
  9. package/dist/lib/command-templates.js +8 -1
  10. package/dist/lib/observability.d.ts +15 -0
  11. package/dist/lib/observability.js +103 -18
  12. package/dist/lib/recipe-discovery.js +13 -5
  13. package/dist/lib/recipe-references.js +137 -11
  14. package/dist/lib/runtime-notifier.d.ts +48 -0
  15. package/dist/lib/runtime-notifier.js +137 -0
  16. package/dist/lib/tools.js +18 -9
  17. package/docs/README.md +1 -1
  18. package/docs/actor-messages.md +8 -3
  19. package/docs/async-runs.md +7 -5
  20. package/docs/recipe-library.md +25 -8
  21. package/docs/template-recipes.md +37 -7
  22. package/docs/tool-registry.md +4 -3
  23. package/index.ts +14 -0
  24. package/lib/actor-rooms.ts +46 -1
  25. package/lib/async-runs.ts +433 -49
  26. package/lib/command-templates.ts +8 -1
  27. package/lib/observability.ts +133 -20
  28. package/lib/recipe-discovery.ts +21 -6
  29. package/lib/recipe-references.ts +141 -17
  30. package/lib/runtime-notifier.ts +207 -0
  31. package/lib/tools.ts +36 -13
  32. package/package.json +2 -3
  33. package/recipes/music-player.json +1 -1
  34. package/recipes/pipeline-room-swarm.json +1 -1
  35. package/scripts/coordinator.mjs +276 -135
  36. package/scripts/locker.mjs +87 -28
  37. package/scripts/music-player.mjs +401 -94
  38. package/scripts/validate-recipe.mjs +2 -2
  39. package/skills/actors/SKILL.md +10 -9
  40. package/skills/swarm/SKILL.md +1 -1
  41. package/index.js +0 -19
package/lib/async-runs.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
6
 
7
- import { spawn } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
8
+ import { spawn, spawnSync } from "node:child_process";
8
9
  import {
9
10
  closeSync,
10
11
  constants,
@@ -19,6 +20,7 @@ import {
19
20
  writeFileSync,
20
21
  writeSync,
21
22
  } from "node:fs";
23
+ import { createConnection } from "node:net";
22
24
  import { platform } from "node:os";
23
25
  import { basename, dirname, extname, join, resolve } from "node:path";
24
26
  import { fileURLToPath } from "node:url";
@@ -32,13 +34,21 @@ import { writeJsonAtomic } from "./file-state.ts";
32
34
  import * as Paths from "./paths.ts";
33
35
  import * as RecipeReferences from "./recipe-references.ts";
34
36
  import * as RecipeUsage from "./recipe-usage.ts";
37
+ import { notifyRuntimeWake } from "./runtime-notifier.ts";
35
38
 
36
39
  const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
40
+ const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
37
41
 
38
42
  export type AsyncRunLaunchSource = "spawn" | "tool";
39
43
 
44
+ export interface AsyncRunControlEndpoint {
45
+ path: string;
46
+ type: "fifo" | "mailbox" | "named-pipe";
47
+ }
48
+
40
49
  export interface AsyncRunStartParams {
41
50
  async?: boolean;
51
+ control?: AsyncRunControlEndpoint;
42
52
  file?: string;
43
53
  launch_source?: AsyncRunLaunchSource;
44
54
  name?: string;
@@ -112,6 +122,7 @@ export interface AsyncRunMeta {
112
122
  template: CommandTemplateValue;
113
123
  values: Record<string, unknown>;
114
124
  artifacts?: Record<string, string>;
125
+ control?: AsyncRunControlEndpoint;
115
126
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
116
127
  recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
117
128
  retire_when?: "children_terminal";
@@ -207,7 +218,10 @@ function assertNoActiveRunState(stateDir: string): void {
207
218
  }
208
219
 
209
220
  function resolveRecipeFile(file: string): string {
210
- return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
221
+ return (
222
+ RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
223
+ RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
224
+ );
211
225
  }
212
226
 
213
227
  function isMutableUsageRecipeFile(file: string): boolean {
@@ -446,6 +460,7 @@ export function startRun(
446
460
  template: resolved.template,
447
461
  values,
448
462
  ...(artifacts ? { artifacts } : {}),
463
+ ...(startParams.control ? { control: startParams.control } : {}),
449
464
  ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
450
465
  ...(recipeContextRecords && recipeContextRecords.length > 0
451
466
  ? { recipe_context_records: recipeContextRecords }
@@ -650,6 +665,193 @@ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
650
665
  .filter((event): event is RunOutboxEvent => Boolean(event));
651
666
  }
652
667
 
668
+ export type RunInboxStatus =
669
+ | "queued"
670
+ | "sent"
671
+ | "claimed"
672
+ | "handled"
673
+ | "failed";
674
+
675
+ export type RunInboxMessage = Record<string, unknown> & {
676
+ id?: string;
677
+ status?: RunInboxStatus | string;
678
+ };
679
+
680
+ export interface ProcessRunInboxResult {
681
+ claimed: number;
682
+ failed: number;
683
+ handled: number;
684
+ }
685
+
686
+ function runInboxFile(stateDir: string): string {
687
+ return join(stateDir, "inbox.jsonl");
688
+ }
689
+
690
+ function sleepSync(ms: number): void {
691
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
692
+ }
693
+
694
+ function acquireRunInboxLock(stateDir: string): () => void {
695
+ const lockDir = join(stateDir, ".inbox.lock");
696
+ const started = Date.now();
697
+ while (true) {
698
+ try {
699
+ mkdirSync(lockDir, { recursive: false });
700
+ writeFileSync(
701
+ join(lockDir, "owner.json"),
702
+ `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
703
+ "utf8",
704
+ );
705
+ return () => rmSync(lockDir, { recursive: true, force: true });
706
+ } catch (error) {
707
+ try {
708
+ const stat = statSync(lockDir);
709
+ if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
710
+ rmSync(lockDir, { recursive: true, force: true });
711
+ continue;
712
+ }
713
+ } catch {
714
+ continue;
715
+ }
716
+ if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
717
+ throw new Error("Run inbox lock timed out.", { cause: error });
718
+ }
719
+ sleepSync(10);
720
+ }
721
+ }
722
+ }
723
+
724
+ function parseRunInboxLine(line: string): RunInboxMessage | undefined {
725
+ try {
726
+ return JSON.parse(line) as RunInboxMessage;
727
+ } catch {
728
+ return undefined;
729
+ }
730
+ }
731
+
732
+ function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
733
+ const file = runInboxFile(stateDir);
734
+ if (!existsSync(file)) return [];
735
+ return readFileSync(file, "utf8")
736
+ .split("\n")
737
+ .filter((line) => line.trim())
738
+ .map(parseRunInboxLine)
739
+ .filter((message): message is RunInboxMessage => Boolean(message));
740
+ }
741
+
742
+ function writeRunInboxMessages(
743
+ stateDir: string,
744
+ messages: RunInboxMessage[],
745
+ ): void {
746
+ writeFileSync(
747
+ runInboxFile(stateDir),
748
+ messages.length
749
+ ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
750
+ : "",
751
+ "utf8",
752
+ );
753
+ }
754
+
755
+ export function readRunInboxMessages(
756
+ runOrDir: string,
757
+ lines = 40,
758
+ ): RunInboxMessage[] {
759
+ const status = getRunStatus(runOrDir);
760
+ const stateDir = String(status.state_dir);
761
+ return tailLines(runInboxFile(stateDir), lines)
762
+ .map(parseRunInboxLine)
763
+ .filter((message): message is RunInboxMessage => Boolean(message));
764
+ }
765
+
766
+ export function updateRunInboxMessageStatus(
767
+ runOrDir: string,
768
+ id: string,
769
+ nextStatus: RunInboxStatus,
770
+ metadata: Record<string, unknown> = {},
771
+ ): boolean {
772
+ const status = getRunStatus(runOrDir);
773
+ const stateDir = String(status.state_dir);
774
+ const releaseLock = acquireRunInboxLock(stateDir);
775
+ try {
776
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
777
+ const timestampKey = `${nextStatus}_at`;
778
+ let changed = false;
779
+ const updated = messages.map((message) => {
780
+ if (message.id !== id) return message;
781
+ changed = true;
782
+ return {
783
+ ...message,
784
+ ...metadata,
785
+ [timestampKey]: new Date().toISOString(),
786
+ status: nextStatus,
787
+ };
788
+ });
789
+ if (changed) writeRunInboxMessages(stateDir, updated);
790
+ return changed;
791
+ } finally {
792
+ releaseLock();
793
+ }
794
+ }
795
+
796
+ export function claimRunInboxMessage(
797
+ runOrDir: string,
798
+ owner = "runtime",
799
+ statuses: string[] = ["queued"],
800
+ ): RunInboxMessage | undefined {
801
+ const status = getRunStatus(runOrDir);
802
+ const stateDir = String(status.state_dir);
803
+ const releaseLock = acquireRunInboxLock(stateDir);
804
+ try {
805
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
806
+ const index = messages.findIndex((message) =>
807
+ statuses.includes(String(message.status ?? "queued")),
808
+ );
809
+ if (index < 0) return undefined;
810
+ const claimed = {
811
+ ...messages[index],
812
+ claimed_at: new Date().toISOString(),
813
+ claimed_by: owner,
814
+ id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
815
+ status: "claimed",
816
+ } satisfies RunInboxMessage;
817
+ messages[index] = claimed;
818
+ writeRunInboxMessages(stateDir, messages);
819
+ return claimed;
820
+ } finally {
821
+ releaseLock();
822
+ }
823
+ }
824
+
825
+ export async function processRunInboxMessages(
826
+ runOrDir: string,
827
+ handler: (message: RunInboxMessage) => Promise<void> | void,
828
+ options: { limit?: number; owner?: string; statuses?: string[] } = {},
829
+ ): Promise<ProcessRunInboxResult> {
830
+ const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
831
+ const limit = Math.max(1, Number(options.limit ?? 1));
832
+ const owner = options.owner ?? "runtime";
833
+ for (let index = 0; index < limit; index += 1) {
834
+ const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
835
+ if (!message?.id) break;
836
+ result.claimed += 1;
837
+ try {
838
+ await handler(message);
839
+ if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
840
+ result.handled += 1;
841
+ }
842
+ } catch (error) {
843
+ if (
844
+ updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
845
+ error: error instanceof Error ? error.message : String(error),
846
+ })
847
+ ) {
848
+ result.failed += 1;
849
+ }
850
+ }
851
+ }
852
+ return result;
853
+ }
854
+
653
855
  export function appendRunOutboxEvent(
654
856
  runOrDir: string,
655
857
  event: {
@@ -700,15 +902,153 @@ export function appendRunOutboxEvent(
700
902
  };
701
903
  }
702
904
 
703
- export function sendRunMessage(
704
- runOrDir: string,
705
- message: string,
706
- ): Record<string, unknown> {
707
- if (process.platform === "win32") {
708
- throw new Error(
709
- "run actor messages require Unix FIFO support; use WSL/Linux/macOS or a recipe-specific Windows transport.",
905
+ export interface SendRunMessageOptions {
906
+ namedPipeSend?: (path: string, payload: string) => Promise<number>;
907
+ platform?: NodeJS.Platform;
908
+ }
909
+
910
+ function getRunControlEndpoint(
911
+ status: Record<string, unknown>,
912
+ stateDir: string,
913
+ ): AsyncRunControlEndpoint {
914
+ const control = status.control;
915
+ if (control && typeof control === "object" && !Array.isArray(control)) {
916
+ const record = control as Record<string, unknown>;
917
+ if (
918
+ (record.type === "fifo" ||
919
+ record.type === "mailbox" ||
920
+ record.type === "named-pipe") &&
921
+ typeof record.path === "string" &&
922
+ record.path.trim()
923
+ ) {
924
+ return { path: record.path, type: record.type };
925
+ }
926
+ }
927
+ return { path: join(stateDir, "control.fifo"), type: "fifo" };
928
+ }
929
+
930
+ function appendRunInboxMessage(stateDir: string, message: string): string {
931
+ const id = randomUUID();
932
+ const ts = new Date().toISOString();
933
+ let record: Record<string, unknown>;
934
+ try {
935
+ const parsed = JSON.parse(message) as unknown;
936
+ record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
937
+ ? (parsed as Record<string, unknown>)
938
+ : { body: parsed, type: "run.message" };
939
+ } catch {
940
+ record = { body: message, type: "run.message" };
941
+ }
942
+ const releaseLock = acquireRunInboxLock(stateDir);
943
+ try {
944
+ writeFileSync(
945
+ runInboxFile(stateDir),
946
+ `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
947
+ { flag: "a" },
710
948
  );
949
+ } finally {
950
+ releaseLock();
951
+ }
952
+ return id;
953
+ }
954
+
955
+ function writeRunMessageReceipt(
956
+ stateDir: string,
957
+ message: string,
958
+ bytes: number,
959
+ inboxId: string,
960
+ ): void {
961
+ const trimmedMessage = message.trim().toLowerCase();
962
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
963
+ trimmedMessage,
964
+ );
965
+ const ts = new Date().toISOString();
966
+ writeFileSync(
967
+ join(stateDir, "events.jsonl"),
968
+ `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
969
+ { flag: "a" },
970
+ );
971
+ updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
972
+ if (terminalMessage) {
973
+ markTerminalHandled(stateDir, {
974
+ event: "run.message",
975
+ message: trimmedMessage,
976
+ });
977
+ }
978
+ }
979
+
980
+ function sendRunMessageToFifo(
981
+ endpoint: AsyncRunControlEndpoint,
982
+ payload: string,
983
+ ): number {
984
+ if (!existsSync(endpoint.path))
985
+ throw new Error(`Run control FIFO not found: ${endpoint.path}`);
986
+ const stat = statSync(endpoint.path);
987
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
988
+ throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
711
989
  }
990
+ let fd: number | undefined;
991
+ try {
992
+ fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
993
+ return writeSync(fd, payload);
994
+ } finally {
995
+ if (fd !== undefined) closeSync(fd);
996
+ }
997
+ }
998
+
999
+ function notifyRunMessageWake(
1000
+ stateDir: string,
1001
+ run: string,
1002
+ bytes: number,
1003
+ endpoint: AsyncRunControlEndpoint,
1004
+ inboxId: string,
1005
+ ): Record<string, unknown> | undefined {
1006
+ try {
1007
+ const event = notifyRuntimeWake(stateDir, {
1008
+ actor: `run:${run}`,
1009
+ metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
1010
+ reason: "run.message",
1011
+ });
1012
+ return { wake: "wake.jsonl", wake_id: event.id };
1013
+ } catch {
1014
+ return undefined;
1015
+ }
1016
+ }
1017
+
1018
+ function sendRunMessageToNamedPipe(
1019
+ endpoint: AsyncRunControlEndpoint,
1020
+ payload: string,
1021
+ send?: (path: string, payload: string) => Promise<number>,
1022
+ ): Promise<number> {
1023
+ if (send) return send(endpoint.path, payload);
1024
+ return new Promise((resolve, reject) => {
1025
+ const socket = createConnection(endpoint.path);
1026
+ let settled = false;
1027
+ const timeout = setTimeout(() => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ socket.destroy();
1031
+ reject(new Error("named pipe connection timed out"));
1032
+ }, 5000);
1033
+ const finish = (error?: Error): void => {
1034
+ if (settled) return;
1035
+ settled = true;
1036
+ clearTimeout(timeout);
1037
+ if (error) reject(error);
1038
+ else resolve(Buffer.byteLength(payload));
1039
+ };
1040
+ socket.on("error", finish);
1041
+ socket.on("connect", () => {
1042
+ socket.end(payload, () => finish());
1043
+ });
1044
+ });
1045
+ }
1046
+
1047
+ export async function sendRunMessage(
1048
+ runOrDir: string,
1049
+ message: string,
1050
+ options: SendRunMessageOptions = {},
1051
+ ): Promise<Record<string, unknown>> {
712
1052
  const status = getRunStatus(runOrDir);
713
1053
  const stateDir = String(status.state_dir);
714
1054
  const run = String(status.run ?? runOrDir);
@@ -718,64 +1058,108 @@ export function sendRunMessage(
718
1058
  if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
719
1059
  if (!pidMatchesRun(pid, String(status.cwd), stateDir))
720
1060
  throw new Error(`Run pid owner mismatch: ${run}`);
721
- const controlPath = join(stateDir, "control.fifo");
722
- if (!existsSync(controlPath))
723
- throw new Error(`Run control FIFO not found: ${controlPath}`);
724
- const stat = statSync(controlPath);
725
- if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
726
- throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
727
- }
1061
+ const endpoint = getRunControlEndpoint(status, stateDir);
728
1062
  const payload = message.endsWith("\n") ? message : `${message}\n`;
729
- let fd: number | undefined;
1063
+ const payloadBytes = Buffer.byteLength(payload);
1064
+ const inboxId = appendRunInboxMessage(stateDir, message);
1065
+ const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
1066
+ const runtimePlatform = options.platform ?? process.platform;
1067
+ if (endpoint.type === "mailbox") {
1068
+ return {
1069
+ ...(wake ?? {}),
1070
+ bytes: payloadBytes,
1071
+ control: "inbox.jsonl",
1072
+ control_path: endpoint.path,
1073
+ control_type: endpoint.type,
1074
+ queued: true,
1075
+ run,
1076
+ sent: true,
1077
+ state_dir: stateDir,
1078
+ };
1079
+ }
730
1080
  try {
731
- fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
732
- const bytes = writeSync(fd, payload);
733
- const trimmedMessage = message.trim().toLowerCase();
734
- const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
735
- trimmedMessage,
736
- );
737
- const ts = new Date().toISOString();
738
- writeFileSync(
739
- join(stateDir, "events.jsonl"),
740
- `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
741
- { flag: "a" },
742
- );
743
- try {
744
- const envelope = JSON.parse(message) as Record<string, unknown>;
745
- writeFileSync(
746
- join(stateDir, "inbox.jsonl"),
747
- `${JSON.stringify({ ...envelope, received_at: ts })}\n`,
748
- { flag: "a" },
749
- );
750
- } catch {
751
- // Plain control lines are already represented in events.jsonl.
752
- }
753
- if (terminalMessage) {
754
- markTerminalHandled(stateDir, {
755
- event: "run.message",
756
- message: trimmedMessage,
757
- });
1081
+ if (endpoint.type === "fifo") {
1082
+ if (runtimePlatform === "win32") {
1083
+ throw new Error(
1084
+ "run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
1085
+ );
1086
+ }
1087
+ const bytes = sendRunMessageToFifo(endpoint, payload);
1088
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
1089
+ return {
1090
+ ...(wake ?? {}),
1091
+ bytes,
1092
+ control: "control.fifo",
1093
+ control_path: endpoint.path,
1094
+ control_type: endpoint.type,
1095
+ run,
1096
+ sent: true,
1097
+ state_dir: stateDir,
1098
+ };
758
1099
  }
1100
+ const bytes = await sendRunMessageToNamedPipe(
1101
+ endpoint,
1102
+ payload,
1103
+ options.namedPipeSend,
1104
+ );
1105
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
759
1106
  return {
1107
+ ...(wake ?? {}),
760
1108
  bytes,
761
- control: "control.fifo",
1109
+ control: endpoint.path,
1110
+ control_path: endpoint.path,
1111
+ control_type: endpoint.type,
762
1112
  run,
763
1113
  sent: true,
764
1114
  state_dir: stateDir,
765
1115
  };
766
1116
  } catch (error) {
767
1117
  throw new Error(
768
- `Run control FIFO is not ready: ${controlPath}: ${error instanceof Error ? error.message : String(error)}`,
1118
+ `Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`,
769
1119
  );
770
- } finally {
771
- if (fd !== undefined) closeSync(fd);
772
1120
  }
773
1121
  }
774
1122
 
1123
+ export interface RunProcessSignalPlan {
1124
+ args?: string[];
1125
+ command?: string;
1126
+ signalTarget: "processGroup" | "process" | "processTree";
1127
+ }
1128
+
1129
+ export function getRunProcessSignalPlan(
1130
+ pid: number,
1131
+ signal: NodeJS.Signals,
1132
+ runtimePlatform: NodeJS.Platform = process.platform,
1133
+ ): RunProcessSignalPlan {
1134
+ if (runtimePlatform === "win32") {
1135
+ return {
1136
+ args: [
1137
+ "/PID",
1138
+ String(pid),
1139
+ "/T",
1140
+ ...(signal === "SIGKILL" ? ["/F"] : []),
1141
+ ],
1142
+ command: "taskkill",
1143
+ signalTarget: "processTree",
1144
+ };
1145
+ }
1146
+ return { signalTarget: "processGroup" };
1147
+ }
1148
+
775
1149
  function signalOwnedRunProcess(
776
1150
  pid: number,
777
1151
  signal: NodeJS.Signals,
778
- ): { signalTarget: "processGroup" | "process" } {
1152
+ ): RunProcessSignalPlan {
1153
+ const plan = getRunProcessSignalPlan(pid, signal);
1154
+ if (plan.command && plan.args) {
1155
+ const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
1156
+ if (result.status !== 0) {
1157
+ throw new Error(
1158
+ result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`,
1159
+ );
1160
+ }
1161
+ return plan;
1162
+ }
779
1163
  try {
780
1164
  process.kill(-pid, signal);
781
1165
  return { signalTarget: "processGroup" };
@@ -157,8 +157,15 @@ function getExecutableName(command: string | undefined): string {
157
157
  return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
158
158
  }
159
159
 
160
+ function matchesFlag(arg: string, flag: string): boolean {
161
+ if (arg === flag) return true;
162
+ if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
163
+ return arg.slice(1).includes(flag.slice(1));
164
+ return false;
165
+ }
166
+
160
167
  function hasAnyFlag(args: string[], flags: string[]): boolean {
161
- return args.some((arg) => flags.includes(arg));
168
+ return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
162
169
  }
163
170
 
164
171
  function hasRiskyPathArg(args: string[]): boolean {