@llblab/pi-actors 0.20.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +0 -85
- package/CHANGELOG.md +17 -0
- package/README.md +7 -1
- package/dist/index.js +11 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +15 -1
- package/dist/lib/async-runs.d.ts +17 -1
- package/dist/lib/async-runs.js +127 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/tools.js +5 -5
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +4 -1
- package/docs/async-runs.md +3 -3
- package/docs/recipe-library.md +14 -4
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +23 -1
- package/lib/async-runs.ts +186 -48
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/tools.ts +10 -8
- package/package.json +1 -1
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +60 -32
- package/scripts/locker.mjs +61 -23
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
package/docs/template-recipes.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Template Recipe Standard
|
|
2
2
|
|
|
3
|
-
Template recipes are saved
|
|
3
|
+
Template recipes are saved definitions around the synchronous [Command Template Standard](./command-templates.md). JSON remains the canonical precise format; Markdown is a literate authoring format that compiles into the same recipe model.
|
|
4
4
|
|
|
5
5
|
**Meta-contract:** a recipe stores a command-template graph plus defaults and run mode. It does not create a second execution language.
|
|
6
6
|
|
|
7
|
-
**Scope:** reusable JSON shape, recipe naming, file-backed recipes, co-located recipes, recipe-layer imports/references, call-time values, foreground execution, and the `async: true` handoff to the [Async Run Standard](./async-runs.md).
|
|
7
|
+
**Scope:** reusable JSON/Markdown shape, recipe naming, file-backed recipes, co-located recipes, recipe-layer imports/references, call-time values, foreground execution, and the `async: true` handoff to the [Async Run Standard](./async-runs.md).
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -27,7 +27,7 @@ Packaged recipes are the pi-actors recipe standard library: declarative actor co
|
|
|
27
27
|
|
|
28
28
|
Template-recipe standard owns:
|
|
29
29
|
|
|
30
|
-
- Saved JSON definitions around one command-template graph.
|
|
30
|
+
- Saved JSON definitions around one command-template graph, plus Markdown-authored recipes that compile to that shape.
|
|
31
31
|
- File-backed and co-located recipe shapes.
|
|
32
32
|
- Recipe identity through file-backed filename or co-located tool id.
|
|
33
33
|
- Recipe defaults, values, imports, import references, and import-node expansion.
|
|
@@ -67,6 +67,33 @@ Async recipe:
|
|
|
67
67
|
|
|
68
68
|
A file-backed recipe's id comes from its filename, not a JSON `name` field. Legacy files may still contain `name`, but loaders ignore it for identity. `template` is the command-template tree. `async: true` selects detached run mode when the recipe is invoked through a registered tool.
|
|
69
69
|
|
|
70
|
+
## Markdown Authoring
|
|
71
|
+
|
|
72
|
+
Markdown recipes use `.md` files with YAML-like frontmatter for recipe metadata and one fenced executable block for the recipe/template body. Runtime behavior comes only from frontmatter plus the fenced block; surrounding prose is advisory for humans and future recipe-context use.
|
|
73
|
+
|
|
74
|
+
````markdown
|
|
75
|
+
---
|
|
76
|
+
description: Literate docs check
|
|
77
|
+
args:
|
|
78
|
+
- scope:path
|
|
79
|
+
defaults:
|
|
80
|
+
scope: docs
|
|
81
|
+
mailbox:
|
|
82
|
+
accepts:
|
|
83
|
+
- control.stop
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
Human notes can explain intent, examples, or review guidance.
|
|
87
|
+
|
|
88
|
+
```template
|
|
89
|
+
npm run check -- {scope}
|
|
90
|
+
```
|
|
91
|
+
````
|
|
92
|
+
|
|
93
|
+
Fenced blocks marked `template`, `command-template`, `json`, or `recipe` are executable. A `template` fence stores its text as the command-template string. A JSON fence can contain either a full recipe object with `template` or a raw command-template value. Frontmatter supports the recipe metadata used by JSON recipes, including `args`, `defaults`, `imports`, `mailbox`, `artifacts`, `async`, and command-template flags.
|
|
94
|
+
|
|
95
|
+
JSON remains the source-of-truth format for precise machine editing. If `<id>.json` and `<id>.md` exist in the same discovery priority layer, `<id>.json` wins and the Markdown recipe is reported as shadowed.
|
|
96
|
+
|
|
70
97
|
## Discovery Priority
|
|
71
98
|
|
|
72
99
|
Recipe priority only matters when two discovered recipes have the same filename id. The conceptual ladder from lowest to highest priority is:
|
|
@@ -74,11 +101,11 @@ Recipe priority only matters when two discovered recipes have the same filename
|
|
|
74
101
|
1. No recipe for that id.
|
|
75
102
|
2. Packaged pi-actors recipe components, acting as the standard library.
|
|
76
103
|
3. Explicitly referenced ad hoc user recipe files located outside `~/.pi/agent/recipes`.
|
|
77
|
-
4. User recipe files under `~/.pi/agent/recipes/*.json`.
|
|
104
|
+
4. User recipe files under `~/.pi/agent/recipes/*.json` or `*.md`.
|
|
78
105
|
|
|
79
106
|
The high-priority user recipe directory is also the default tool set: recipes placed there are agent tools by location. This preserves the old advantage of a tool-only registry because listing `~/.pi/agent/recipes` shows the operator-managed tool surface. Packaged and ad hoc recipes are recipe components by default; they become tools only when copied or registered into the agent recipe root.
|
|
80
107
|
|
|
81
|
-
Higher-priority files shadow lower-priority files with the same basename. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback and intentionally disables that id.
|
|
108
|
+
Higher-priority files shadow lower-priority files with the same basename. Within one priority layer, same-id JSON shadows Markdown because JSON is the canonical precise format. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback and intentionally disables that id.
|
|
82
109
|
|
|
83
110
|
## Usage Metadata
|
|
84
111
|
|
|
@@ -203,19 +230,22 @@ Reusable local recipes live in:
|
|
|
203
230
|
|
|
204
231
|
```text
|
|
205
232
|
~/.pi/agent/recipes/*.json
|
|
233
|
+
~/.pi/agent/recipes/*.md
|
|
206
234
|
```
|
|
207
235
|
|
|
208
236
|
Bare recipe names resolve under that directory, so `file: "review-docs"` loads:
|
|
209
237
|
|
|
210
238
|
```text
|
|
211
239
|
~/.pi/agent/recipes/review-docs.json
|
|
240
|
+
# or, when no same-id JSON file exists:
|
|
241
|
+
~/.pi/agent/recipes/review-docs.md
|
|
212
242
|
```
|
|
213
243
|
|
|
214
244
|
Call-time params override file params. `values` are merged with file values; call-time values win. If a run id is omitted for an explicit async start, the file basename becomes the default run id.
|
|
215
245
|
|
|
216
246
|
## Registered Recipe Tools
|
|
217
247
|
|
|
218
|
-
A registered tool is a recipe file exposed as an agent tool. User recipes under `~/.pi/agent/recipes/*.json` are tools by location; packaged/ad hoc recipes are components unless copied or registered into that user recipe root:
|
|
248
|
+
A registered tool is a recipe file exposed as an agent tool. User recipes under `~/.pi/agent/recipes/*.json` or `*.md` are tools by location; packaged/ad hoc recipes are components unless copied or registered into that user recipe root:
|
|
219
249
|
|
|
220
250
|
```json
|
|
221
251
|
{
|
|
@@ -312,6 +342,6 @@ Nested object keys are dot-separated. Import references are resolved before norm
|
|
|
312
342
|
|
|
313
343
|
## Recipe Shape
|
|
314
344
|
|
|
315
|
-
Use the filename for file-backed recipe ids, and use `async: true` for detached runs. Use `parallel: true` for fanout, `when` for node guards, and semantic public args such as `tools`, `all`, or `timeout_ms` instead of leaking CLI fragments or reusing node-control names. Local files belong under `~/.pi/agent/recipes/*.json` before relying on recipe launchers.
|
|
345
|
+
Use the filename for file-backed recipe ids, and use `async: true` for detached runs. Use `parallel: true` for fanout, `when` for node guards, and semantic public args such as `tools`, `all`, or `timeout_ms` instead of leaking CLI fragments or reusing node-control names. Local files belong under `~/.pi/agent/recipes/*.json` or `*.md` before relying on recipe launchers.
|
|
316
346
|
|
|
317
347
|
If a proposed recipe needs a scheduler, queue daemon, `goto`, or custom workflow syntax, stop. Keep the recipe as saved command-template JSON and put policy in the registered tool, script, or caller.
|
package/docs/tool-registry.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Tool Registry
|
|
2
2
|
|
|
3
|
-
`pi-actors` stores persistent agent tools as recipe files under `~/.pi/agent/recipes/*.json` and registers the active tool set automatically on session start.
|
|
3
|
+
`pi-actors` stores persistent agent tools as recipe files under `~/.pi/agent/recipes/*.json` or `*.md` and registers the active tool set automatically on session start.
|
|
4
4
|
|
|
5
5
|
This document is the local adaptation of the portable [Command Template Standard](./command-templates.md) and the recipe-file runtime described in [Template Recipe Standard](./template-recipes.md).
|
|
6
6
|
|
|
@@ -8,11 +8,12 @@ This document is the local adaptation of the portable [Command Template Standard
|
|
|
8
8
|
|
|
9
9
|
The registry source is location-discovered recipes, not a live tool-only JSON file and not a recipe-owned boolean:
|
|
10
10
|
|
|
11
|
-
- `~/.pi/agent/recipes/*.json`
|
|
11
|
+
- `~/.pi/agent/recipes/*.json` and `*.md` are the highest-priority user recipe root and the operator-managed tool set.
|
|
12
12
|
- Recipes in that root are tools by location.
|
|
13
13
|
- Packaged pi-actors recipes are the lower-priority standard library of declarative actor config components, not automatically registered tools.
|
|
14
14
|
- Ad hoc recipe files outside the user recipe root are components unless explicitly registered/copied into `~/.pi/agent/recipes`.
|
|
15
|
-
- Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json`
|
|
15
|
+
- Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
|
|
16
|
+
- Same-id JSON shadows Markdown in the same priority layer.
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` on user-owned recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning rather than an older file history. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
|
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
14
|
|
|
15
15
|
import * as ActorInspectorTui from "./lib/actor-inspector-tui.ts";
|
|
16
|
+
import * as AsyncRuns from "./lib/async-runs.ts";
|
|
16
17
|
import * as CommandTemplates from "./lib/command-templates.ts";
|
|
17
18
|
import * as Observability from "./lib/observability.ts";
|
|
18
19
|
import * as Paths from "./lib/paths.ts";
|
|
@@ -47,6 +48,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
47
48
|
const runDirWatchers = new Map<string, FSWatcher>();
|
|
48
49
|
const observedRuns = new Map<string, Observability.RunObservedStatus>();
|
|
49
50
|
const observedRunEventLines = new Map<string, number>();
|
|
51
|
+
const retirementAttempts = new Set<string>();
|
|
50
52
|
let runStatusFrame = 0;
|
|
51
53
|
let communicationWidgetVisible = false;
|
|
52
54
|
let actorInspectorRows = 12;
|
|
@@ -61,6 +63,17 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
61
63
|
let recipeWatcherFailureNotified = false;
|
|
62
64
|
const getRunOwnerId = (ctx: ExtensionContext): string =>
|
|
63
65
|
ctx.sessionManager.getSessionId();
|
|
66
|
+
const retireCandidateRuns = (
|
|
67
|
+
ctx: ExtensionContext,
|
|
68
|
+
summary: Observability.RunSummary,
|
|
69
|
+
): void => {
|
|
70
|
+
void Observability.executeRunRetirements(summary, {
|
|
71
|
+
attempted: retirementAttempts,
|
|
72
|
+
cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
|
|
73
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
74
|
+
sendStop: (candidate) => AsyncRuns.sendRunMessage(candidate.stateDir, "stop"),
|
|
75
|
+
});
|
|
76
|
+
};
|
|
64
77
|
const updateRunUi = (ctx: ExtensionContext, notify = false): void => {
|
|
65
78
|
const ownerId = getRunOwnerId(ctx);
|
|
66
79
|
const summary = Observability.summarizeRuns(undefined, ownerId);
|
|
@@ -139,6 +152,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
139
152
|
summary,
|
|
140
153
|
);
|
|
141
154
|
if (!notify) return;
|
|
155
|
+
retireCandidateRuns(ctx, summary);
|
|
142
156
|
for (const transition of transitions) {
|
|
143
157
|
if (!Observability.shouldNotifyRunTransition(transition)) continue;
|
|
144
158
|
const text = Observability.formatRunTransitionMessage(transition);
|
package/lib/actor-rooms.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ActorMessage } from "./actor-messages.ts";
|
|
|
13
13
|
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
14
14
|
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
15
15
|
const DEFAULT_ROOM_MAX_MESSAGES = 10000;
|
|
16
|
+
const DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED = 2000;
|
|
16
17
|
const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 250;
|
|
17
18
|
|
|
18
19
|
export interface RoomMember {
|
|
@@ -386,6 +387,26 @@ export function readBranchInboxMessages(
|
|
|
386
387
|
}
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
export function getBranchInboxTerminalRetainLimit(): number {
|
|
391
|
+
const value = Number(process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "");
|
|
392
|
+
return Number.isInteger(value) && value >= 0
|
|
393
|
+
? value
|
|
394
|
+
: DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function compactBranchInboxMessages<T extends { status?: string }>(
|
|
398
|
+
messages: T[],
|
|
399
|
+
): T[] {
|
|
400
|
+
const retainTerminal = getBranchInboxTerminalRetainLimit();
|
|
401
|
+
const active = messages.filter(
|
|
402
|
+
(message) => message.status !== "handled" && message.status !== "failed",
|
|
403
|
+
);
|
|
404
|
+
const terminal = messages.filter(
|
|
405
|
+
(message) => message.status === "handled" || message.status === "failed",
|
|
406
|
+
);
|
|
407
|
+
return [...terminal.slice(-retainTerminal), ...active];
|
|
408
|
+
}
|
|
409
|
+
|
|
389
410
|
export function appendBranchInboxMessage(
|
|
390
411
|
stateDir: string,
|
|
391
412
|
run: string,
|
|
@@ -428,7 +449,8 @@ export function updateBranchInboxMessageStatus(
|
|
|
428
449
|
return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
|
|
429
450
|
});
|
|
430
451
|
if (!changed) return false;
|
|
431
|
-
|
|
452
|
+
const compacted = compactBranchInboxMessages(updated);
|
|
453
|
+
fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
|
|
432
454
|
return true;
|
|
433
455
|
} finally {
|
|
434
456
|
releaseLock();
|
package/lib/async-runs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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 { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import {
|
|
9
9
|
closeSync,
|
|
10
10
|
constants,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
writeFileSync,
|
|
20
20
|
writeSync,
|
|
21
21
|
} from "node:fs";
|
|
22
|
+
import { createConnection } from "node:net";
|
|
22
23
|
import { platform } from "node:os";
|
|
23
24
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -37,8 +38,14 @@ const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
37
38
|
|
|
38
39
|
export type AsyncRunLaunchSource = "spawn" | "tool";
|
|
39
40
|
|
|
41
|
+
export interface AsyncRunControlEndpoint {
|
|
42
|
+
path: string;
|
|
43
|
+
type: "fifo" | "named-pipe";
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
export interface AsyncRunStartParams {
|
|
41
47
|
async?: boolean;
|
|
48
|
+
control?: AsyncRunControlEndpoint;
|
|
42
49
|
file?: string;
|
|
43
50
|
launch_source?: AsyncRunLaunchSource;
|
|
44
51
|
name?: string;
|
|
@@ -112,6 +119,7 @@ export interface AsyncRunMeta {
|
|
|
112
119
|
template: CommandTemplateValue;
|
|
113
120
|
values: Record<string, unknown>;
|
|
114
121
|
artifacts?: Record<string, string>;
|
|
122
|
+
control?: AsyncRunControlEndpoint;
|
|
115
123
|
mailbox?: RecipeReferences.TemplateRecipeMailbox;
|
|
116
124
|
recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
|
|
117
125
|
retire_when?: "children_terminal";
|
|
@@ -207,7 +215,10 @@ function assertNoActiveRunState(stateDir: string): void {
|
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
function resolveRecipeFile(file: string): string {
|
|
210
|
-
return
|
|
218
|
+
return (
|
|
219
|
+
RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
|
|
220
|
+
RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
|
|
221
|
+
);
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
function isMutableUsageRecipeFile(file: string): boolean {
|
|
@@ -446,6 +457,7 @@ export function startRun(
|
|
|
446
457
|
template: resolved.template,
|
|
447
458
|
values,
|
|
448
459
|
...(artifacts ? { artifacts } : {}),
|
|
460
|
+
...(startParams.control ? { control: startParams.control } : {}),
|
|
449
461
|
...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
|
|
450
462
|
...(recipeContextRecords && recipeContextRecords.length > 0
|
|
451
463
|
? { recipe_context_records: recipeContextRecords }
|
|
@@ -700,15 +712,115 @@ export function appendRunOutboxEvent(
|
|
|
700
712
|
};
|
|
701
713
|
}
|
|
702
714
|
|
|
703
|
-
export
|
|
704
|
-
|
|
715
|
+
export interface SendRunMessageOptions {
|
|
716
|
+
namedPipeSend?: (path: string, payload: string) => Promise<number>;
|
|
717
|
+
platform?: NodeJS.Platform;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function getRunControlEndpoint(
|
|
721
|
+
status: Record<string, unknown>,
|
|
722
|
+
stateDir: string,
|
|
723
|
+
): AsyncRunControlEndpoint {
|
|
724
|
+
const control = status.control;
|
|
725
|
+
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
726
|
+
const record = control as Record<string, unknown>;
|
|
727
|
+
if (
|
|
728
|
+
(record.type === "fifo" || record.type === "named-pipe") &&
|
|
729
|
+
typeof record.path === "string" &&
|
|
730
|
+
record.path.trim()
|
|
731
|
+
) {
|
|
732
|
+
return { path: record.path, type: record.type };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function writeRunMessageReceipt(
|
|
739
|
+
stateDir: string,
|
|
705
740
|
message: string,
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
741
|
+
bytes: number,
|
|
742
|
+
): void {
|
|
743
|
+
const trimmedMessage = message.trim().toLowerCase();
|
|
744
|
+
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
|
|
745
|
+
trimmedMessage,
|
|
746
|
+
);
|
|
747
|
+
const ts = new Date().toISOString();
|
|
748
|
+
writeFileSync(
|
|
749
|
+
join(stateDir, "events.jsonl"),
|
|
750
|
+
`${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
|
|
751
|
+
{ flag: "a" },
|
|
752
|
+
);
|
|
753
|
+
try {
|
|
754
|
+
const envelope = JSON.parse(message) as Record<string, unknown>;
|
|
755
|
+
writeFileSync(
|
|
756
|
+
join(stateDir, "inbox.jsonl"),
|
|
757
|
+
`${JSON.stringify({ ...envelope, received_at: ts })}\n`,
|
|
758
|
+
{ flag: "a" },
|
|
710
759
|
);
|
|
760
|
+
} catch {
|
|
761
|
+
// Plain control lines are already represented in events.jsonl.
|
|
762
|
+
}
|
|
763
|
+
if (terminalMessage) {
|
|
764
|
+
markTerminalHandled(stateDir, {
|
|
765
|
+
event: "run.message",
|
|
766
|
+
message: trimmedMessage,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function sendRunMessageToFifo(
|
|
772
|
+
endpoint: AsyncRunControlEndpoint,
|
|
773
|
+
payload: string,
|
|
774
|
+
): number {
|
|
775
|
+
if (!existsSync(endpoint.path))
|
|
776
|
+
throw new Error(`Run control FIFO not found: ${endpoint.path}`);
|
|
777
|
+
const stat = statSync(endpoint.path);
|
|
778
|
+
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
779
|
+
throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
|
|
711
780
|
}
|
|
781
|
+
let fd: number | undefined;
|
|
782
|
+
try {
|
|
783
|
+
fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
784
|
+
return writeSync(fd, payload);
|
|
785
|
+
} finally {
|
|
786
|
+
if (fd !== undefined) closeSync(fd);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function sendRunMessageToNamedPipe(
|
|
791
|
+
endpoint: AsyncRunControlEndpoint,
|
|
792
|
+
payload: string,
|
|
793
|
+
send?: (path: string, payload: string) => Promise<number>,
|
|
794
|
+
): Promise<number> {
|
|
795
|
+
if (send) return send(endpoint.path, payload);
|
|
796
|
+
return new Promise((resolve, reject) => {
|
|
797
|
+
const socket = createConnection(endpoint.path);
|
|
798
|
+
let settled = false;
|
|
799
|
+
const timeout = setTimeout(() => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
socket.destroy();
|
|
803
|
+
reject(new Error("named pipe connection timed out"));
|
|
804
|
+
}, 5000);
|
|
805
|
+
const finish = (error?: Error): void => {
|
|
806
|
+
if (settled) return;
|
|
807
|
+
settled = true;
|
|
808
|
+
clearTimeout(timeout);
|
|
809
|
+
if (error) reject(error);
|
|
810
|
+
else resolve(Buffer.byteLength(payload));
|
|
811
|
+
};
|
|
812
|
+
socket.on("error", finish);
|
|
813
|
+
socket.on("connect", () => {
|
|
814
|
+
socket.end(payload, () => finish());
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export async function sendRunMessage(
|
|
820
|
+
runOrDir: string,
|
|
821
|
+
message: string,
|
|
822
|
+
options: SendRunMessageOptions = {},
|
|
823
|
+
): Promise<Record<string, unknown>> {
|
|
712
824
|
const status = getRunStatus(runOrDir);
|
|
713
825
|
const stateDir = String(status.state_dir);
|
|
714
826
|
const run = String(status.run ?? runOrDir);
|
|
@@ -718,64 +830,90 @@ export function sendRunMessage(
|
|
|
718
830
|
if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
|
|
719
831
|
if (!pidMatchesRun(pid, String(status.cwd), stateDir))
|
|
720
832
|
throw new Error(`Run pid owner mismatch: ${run}`);
|
|
721
|
-
const
|
|
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
|
-
}
|
|
833
|
+
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
728
834
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
729
|
-
|
|
835
|
+
const runtimePlatform = options.platform ?? process.platform;
|
|
730
836
|
try {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
});
|
|
837
|
+
if (endpoint.type === "fifo") {
|
|
838
|
+
if (runtimePlatform === "win32") {
|
|
839
|
+
throw new Error(
|
|
840
|
+
"run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
844
|
+
writeRunMessageReceipt(stateDir, message, bytes);
|
|
845
|
+
return {
|
|
846
|
+
bytes,
|
|
847
|
+
control: "control.fifo",
|
|
848
|
+
control_path: endpoint.path,
|
|
849
|
+
control_type: endpoint.type,
|
|
850
|
+
run,
|
|
851
|
+
sent: true,
|
|
852
|
+
state_dir: stateDir,
|
|
853
|
+
};
|
|
758
854
|
}
|
|
855
|
+
const bytes = await sendRunMessageToNamedPipe(
|
|
856
|
+
endpoint,
|
|
857
|
+
payload,
|
|
858
|
+
options.namedPipeSend,
|
|
859
|
+
);
|
|
860
|
+
writeRunMessageReceipt(stateDir, message, bytes);
|
|
759
861
|
return {
|
|
760
862
|
bytes,
|
|
761
|
-
control:
|
|
863
|
+
control: endpoint.path,
|
|
864
|
+
control_path: endpoint.path,
|
|
865
|
+
control_type: endpoint.type,
|
|
762
866
|
run,
|
|
763
867
|
sent: true,
|
|
764
868
|
state_dir: stateDir,
|
|
765
869
|
};
|
|
766
870
|
} catch (error) {
|
|
767
871
|
throw new Error(
|
|
768
|
-
`Run control
|
|
872
|
+
`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
769
873
|
);
|
|
770
|
-
} finally {
|
|
771
|
-
if (fd !== undefined) closeSync(fd);
|
|
772
874
|
}
|
|
773
875
|
}
|
|
774
876
|
|
|
877
|
+
export interface RunProcessSignalPlan {
|
|
878
|
+
args?: string[];
|
|
879
|
+
command?: string;
|
|
880
|
+
signalTarget: "processGroup" | "process" | "processTree";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export function getRunProcessSignalPlan(
|
|
884
|
+
pid: number,
|
|
885
|
+
signal: NodeJS.Signals,
|
|
886
|
+
runtimePlatform: NodeJS.Platform = process.platform,
|
|
887
|
+
): RunProcessSignalPlan {
|
|
888
|
+
if (runtimePlatform === "win32") {
|
|
889
|
+
return {
|
|
890
|
+
args: [
|
|
891
|
+
"/PID",
|
|
892
|
+
String(pid),
|
|
893
|
+
"/T",
|
|
894
|
+
...(signal === "SIGKILL" ? ["/F"] : []),
|
|
895
|
+
],
|
|
896
|
+
command: "taskkill",
|
|
897
|
+
signalTarget: "processTree",
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
return { signalTarget: "processGroup" };
|
|
901
|
+
}
|
|
902
|
+
|
|
775
903
|
function signalOwnedRunProcess(
|
|
776
904
|
pid: number,
|
|
777
905
|
signal: NodeJS.Signals,
|
|
778
|
-
):
|
|
906
|
+
): RunProcessSignalPlan {
|
|
907
|
+
const plan = getRunProcessSignalPlan(pid, signal);
|
|
908
|
+
if (plan.command && plan.args) {
|
|
909
|
+
const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
|
|
910
|
+
if (result.status !== 0) {
|
|
911
|
+
throw new Error(
|
|
912
|
+
result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
return plan;
|
|
916
|
+
}
|
|
779
917
|
try {
|
|
780
918
|
process.kill(-pid, signal);
|
|
781
919
|
return { signalTarget: "processGroup" };
|
package/lib/command-templates.ts
CHANGED
|
@@ -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.
|
|
168
|
+
return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
function hasRiskyPathArg(args: string[]): boolean {
|