@llblab/pi-actors 0.12.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 (86) hide show
  1. package/AGENTS.md +72 -0
  2. package/BACKLOG.md +38 -0
  3. package/CHANGELOG.md +179 -0
  4. package/README.md +338 -0
  5. package/docs/README.md +21 -0
  6. package/docs/actor-messages.md +149 -0
  7. package/docs/async-runs.md +335 -0
  8. package/docs/command-templates.md +424 -0
  9. package/docs/component-recipes.md +148 -0
  10. package/docs/recipe-library.md +176 -0
  11. package/docs/task-first-recipes.md +233 -0
  12. package/docs/template-recipes.md +285 -0
  13. package/docs/tool-registry.md +142 -0
  14. package/index.ts +198 -0
  15. package/lib/actor-messages.ts +120 -0
  16. package/lib/async-runs.ts +688 -0
  17. package/lib/command-templates.ts +795 -0
  18. package/lib/config.ts +266 -0
  19. package/lib/execution.ts +720 -0
  20. package/lib/file-state.ts +24 -0
  21. package/lib/identity.ts +29 -0
  22. package/lib/observability.ts +525 -0
  23. package/lib/output.ts +123 -0
  24. package/lib/paths.ts +35 -0
  25. package/lib/prompts.ts +75 -0
  26. package/lib/recipe-references.ts +586 -0
  27. package/lib/registry.ts +302 -0
  28. package/lib/runtime.ts +101 -0
  29. package/lib/schema.ts +402 -0
  30. package/lib/temp.ts +44 -0
  31. package/lib/tools.ts +651 -0
  32. package/package.json +52 -0
  33. package/recipes/music-player.json +25 -0
  34. package/recipes/pipeline-architect-coordinator.json +88 -0
  35. package/recipes/pipeline-artifact-report.json +52 -0
  36. package/recipes/pipeline-artifact-write.json +66 -0
  37. package/recipes/pipeline-async-run-ops.json +67 -0
  38. package/recipes/pipeline-checkpoint-continuation.json +57 -0
  39. package/recipes/pipeline-development-tasking.json +73 -0
  40. package/recipes/pipeline-docs-maintenance.json +72 -0
  41. package/recipes/pipeline-media-library.json +51 -0
  42. package/recipes/pipeline-quorum-review.json +72 -0
  43. package/recipes/pipeline-release-readiness.json +83 -0
  44. package/recipes/pipeline-repo-health.json +81 -0
  45. package/recipes/pipeline-research-synthesis.json +87 -0
  46. package/recipes/pipeline-review-readiness.json +49 -0
  47. package/recipes/subagent-artifact.json +26 -0
  48. package/recipes/subagent-checkpoint.json +27 -0
  49. package/recipes/subagent-conflict-report.json +25 -0
  50. package/recipes/subagent-contradiction-map.json +26 -0
  51. package/recipes/subagent-critic.json +28 -0
  52. package/recipes/subagent-evidence-map.json +26 -0
  53. package/recipes/subagent-followup.json +27 -0
  54. package/recipes/subagent-judge.json +26 -0
  55. package/recipes/subagent-merge.json +26 -0
  56. package/recipes/subagent-message.json +29 -0
  57. package/recipes/subagent-normalize.json +24 -0
  58. package/recipes/subagent-plan.json +26 -0
  59. package/recipes/subagent-prompt.json +22 -0
  60. package/recipes/subagent-quorum.json +41 -0
  61. package/recipes/subagent-review-coordinator.json +107 -0
  62. package/recipes/subagent-review.json +30 -0
  63. package/recipes/subagent-task-card.json +28 -0
  64. package/recipes/subagent-tools.json +17 -0
  65. package/recipes/subagent-verify.json +27 -0
  66. package/recipes/subagents-prompts.json +32 -0
  67. package/recipes/utility-actor-message.json +24 -0
  68. package/recipes/utility-artifact-manifest.json +17 -0
  69. package/recipes/utility-artifact-write.json +17 -0
  70. package/recipes/utility-changelog-head.json +12 -0
  71. package/recipes/utility-changelog-section.json +14 -0
  72. package/recipes/utility-git-log.json +12 -0
  73. package/recipes/utility-git-status.json +10 -0
  74. package/recipes/utility-jsonl-tail.json +11 -0
  75. package/recipes/utility-markdown-index.json +15 -0
  76. package/recipes/utility-package-summary.json +12 -0
  77. package/recipes/utility-playlist-build.json +18 -0
  78. package/recipes/utility-playlist-scan.json +12 -0
  79. package/recipes/utility-run-state-files.json +14 -0
  80. package/recipes/utility-run-summary.json +12 -0
  81. package/recipes/utility-validate-recipe.json +14 -0
  82. package/recipes/utility-validation-wrapper.json +14 -0
  83. package/scripts/async-runner.mjs +170 -0
  84. package/scripts/music-player.mjs +637 -0
  85. package/scripts/recipe-utils.mjs +273 -0
  86. package/scripts/validate-recipe.mjs +89 -0
@@ -0,0 +1,285 @@
1
+ # Template Recipe Standard
2
+
3
+ Template recipes are saved JSON definitions around the synchronous [Command Template Standard](./command-templates.md).
4
+
5
+ **Meta-contract:** a recipe stores a command-template graph plus defaults and run mode. It does not create a second execution language.
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).
8
+
9
+ ---
10
+
11
+ ## Reading Model
12
+
13
+ ```text
14
+ command template = execution graph
15
+ recipe = saved JSON definition
16
+ run = one execution instance
17
+ async: true = run through detached lifecycle
18
+ ```
19
+
20
+ A recipe wraps one command-template tree. The wrapped `template` keeps the normal command-template semantics: argv splitting, placeholders, defaults, typed args, sequence, `parallel: true`, `when`, delay, retry, failure propagation, recover cleanup, and output selection.
21
+
22
+ Layer boundary: `imports`, `{ "name": "alias" }` imported-recipe nodes, `{alias.defaults.key}` references, fallback expressions, and recipe-local ternaries are recipe-loading features. They resolve before the command-template graph runs and do not extend the portable Command Template Standard. Typed imports are recipe definitions: they expose the imported recipe's command-template-shaped metadata (`template`, `args`, `defaults`, flags, and `values`), while async-run launch fields such as `async` and `state_dir` remain lifecycle configuration for starting a run, not part of the imported execution graph.
23
+
24
+ ## Layer Ownership
25
+
26
+ Template-recipe standard owns:
27
+
28
+ - Saved JSON definitions around one command-template graph.
29
+ - File-backed and co-located recipe shapes.
30
+ - Recipe identity through `name` or filename.
31
+ - Recipe defaults, values, imports, import references, and import-node expansion.
32
+ - Ordered named artifact declarations through `artifacts`.
33
+ - Foreground-vs-detached selection through `async: true` when invoked by a recipe-aware host.
34
+
35
+ Template-recipe standard does not own:
36
+
37
+ - How command-template nodes execute internally.
38
+ - Async state files, logs, FIFO, status, cancellation, or observability.
39
+ - Tool registry naming, button UX, package installation, or operator-specific policy.
40
+ - Domain workflows such as swarm quorum, release policy, backlog parsing, or merge policy.
41
+
42
+ A recipe can be synchronous or asynchronous:
43
+
44
+ - Omitted or false `async`: a registered tool executes the recipe in the foreground and returns normal tool output.
45
+ - `async: true`: a registered tool starts a detached async run and returns run metadata immediately.
46
+
47
+ ## Minimal Shape
48
+
49
+ Synchronous recipe:
50
+
51
+ ```json
52
+ {
53
+ "name": "check-docs",
54
+ "template": "npm run check:docs"
55
+ }
56
+ ```
57
+
58
+ Async recipe:
59
+
60
+ ```json
61
+ {
62
+ "name": "review-docs",
63
+ "async": true,
64
+ "template": "review docs/spec.md"
65
+ }
66
+ ```
67
+
68
+ `name` names the saved definition when an explicit name is needed. File-backed recipes usually omit it because the filename is the canonical recipe id. `template` is the command-template tree. `async: true` selects detached run mode when the recipe is invoked through a registered tool.
69
+
70
+ For object form, keep `template` last. Recipe metadata comes first; executable content stays last.
71
+
72
+ ## Named Artifacts
73
+
74
+ Use recipe-level `artifacts` to declare stable artifact names and paths for the whole recipe, ordered from most important to least important:
75
+
76
+ ```json
77
+ {
78
+ "name": "report-task",
79
+ "args": ["report_path:path"],
80
+ "defaults": { "report_path": "artifacts/report.md" },
81
+ "artifacts": {
82
+ "report": "{report_path}",
83
+ "summary": "artifacts/summary.json"
84
+ },
85
+ "template": "generate-report --out {report_path}"
86
+ }
87
+ ```
88
+
89
+ `output` and `artifacts` are intentionally different. `output` is the command-template primary result selector and defaults to stdout; it participates in sequence/stdin flow. `artifacts` is recipe metadata: an ordered named artifact manifest for humans, async completion events, and downstream tooling. `stdout` remains the default command result channel and is not renamed by `artifacts`.
90
+
91
+ ## Mailbox
92
+
93
+ Use recipe-level `mailbox` to document the semantic messages a recipe actor accepts and emits:
94
+
95
+ ```json
96
+ {
97
+ "mailbox": {
98
+ "accepts": ["control.continue", "control.revise", "control.approve", "control.stop"],
99
+ "emits": ["checkpoint.needs_scope", "branch.done", "run.done"]
100
+ }
101
+ }
102
+ ```
103
+
104
+ `mailbox` is contract metadata, not transport configuration. It should name semantic message types, not FIFO commands, file paths, or CLI fragments.
105
+
106
+ ## Actor Message Delivery
107
+
108
+ Recipes do not declare a second event-delivery policy. A running actor emits addressed messages such as `command.done`, `run.done`, or `checkpoint.needs_input`; the coordinator/runtime decides whether a message stays diagnostic, becomes a notification, or re-enters the agent context. This keeps recipe metadata focused on the actor contract:
109
+
110
+ ```json
111
+ {
112
+ "mailbox": {
113
+ "accepts": ["control.stop"],
114
+ "emits": ["command.done", "run.done", "run.failed"]
115
+ },
116
+ "template": "run-subtask {prompt}"
117
+ }
118
+ ```
119
+
120
+ ## Command-Template Flags At Recipe Top Level
121
+
122
+ Top-level command-template flags may sit beside `name` and `async`:
123
+
124
+ ```json
125
+ {
126
+ "name": "review-docs",
127
+ "async": true,
128
+ "parallel": true,
129
+ "timeout": 300000,
130
+ "failure": "branch",
131
+ "template": ["review-a docs/spec.md", "review-b docs/spec.md"]
132
+ }
133
+ ```
134
+
135
+ Valid command-template flags include `args`, `defaults`, `parallel`, `when`, `label`, `timeout`, `delay`, `output`, `retry`, `failure`, `recover`, and `repeat`.
136
+
137
+ Timeout is disabled by default. Set a positive `timeout` when a recipe should fail closed after a bounded runtime; omit it, or set `0`, for intentionally open-ended runs that will be stopped by async cancellation, such as background audio playback.
138
+
139
+ ## Valid Graph
140
+
141
+ The valid chain is:
142
+
143
+ ```text
144
+ tool → template reference → recipe → run → template
145
+ ```
146
+
147
+ A recipe must define `template` directly. A recipe must not define `tool`, because recipes are saved command-template definitions, not tool indirection layers.
148
+
149
+ A recipe may live in a file or be co-located inside a registered tool entry. Both are storage variants of the same graph.
150
+
151
+ ## File-Backed Recipes
152
+
153
+ Reusable local recipes live in:
154
+
155
+ ```text
156
+ ~/.pi/agent/recipes/*.json
157
+ ```
158
+
159
+ Bare recipe names resolve under that directory, so `file: "review-docs"` loads:
160
+
161
+ ```text
162
+ ~/.pi/agent/recipes/review-docs.json
163
+ ```
164
+
165
+ 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 explicit recipe `name` or file basename becomes the default run id.
166
+
167
+ ## Registered Recipe Tools
168
+
169
+ A registered tool can point at an actor recipe by storing the recipe path or name in `template`:
170
+
171
+ ```json
172
+ {
173
+ "shader_ring": {
174
+ "description": "Start the shader ring recipe",
175
+ "args": ["theme", "out_dir"],
176
+ "template": "shader-ring-8-parallel.json"
177
+ }
178
+ }
179
+ ```
180
+
181
+ If `shader-ring-8-parallel.json` contains `async: true`, calling `shader_ring` starts a detached run and returns metadata. If `async` is omitted or false, calling `shader_ring` executes the recipe foreground and returns normal tool output.
182
+
183
+ A registered tool may also co-locate an actor recipe directly in `tools.json`:
184
+
185
+ ```json
186
+ {
187
+ "review_docs": {
188
+ "description": "Start an async docs review",
189
+ "name": "review-docs",
190
+ "async": true,
191
+ "template": "review {scope}"
192
+ }
193
+ }
194
+ ```
195
+
196
+ The co-located entry must still own `template` directly and must not define `tool`.
197
+
198
+ ## Values And Public Args
199
+
200
+ Recipe placeholders come from runtime values, recipe `defaults`, inline placeholder defaults, and registered-tool defaults.
201
+
202
+ Recipe tools derive public arguments from the referenced or co-located command template when the recipe is available locally. Explicit `args` is still available when the public tool surface should be narrower than the recipe internals.
203
+
204
+ Example: a recipe may expose a private `repo` default for an example script, while the registered public tool only asks for `file`, `volume`, and `player`.
205
+
206
+ ## Recipe Imports
207
+
208
+ File-backed recipes may import other file-backed recipes at the recipe layer. Imports are resolved before the command-template graph is executed, so command-template core stays registry-free and synchronous.
209
+
210
+ ```json
211
+ {
212
+ "name": "parent",
213
+ "imports": {
214
+ "prepare": "prepare-worktree.json",
215
+ "test": {
216
+ "from": "run-tests.json",
217
+ "values": { "suite": "unit" }
218
+ }
219
+ },
220
+ "template": [{ "name": "prepare" }, { "name": "test" }]
221
+ }
222
+ ```
223
+
224
+ An import binding may be either a string recipe path/name or an object with:
225
+
226
+ - `from`: recipe path or bare name.
227
+ - `defaults`: extra default values exposed through the import.
228
+ - `values`: explicit values for embedding that imported recipe.
229
+
230
+ A template node of `{ "name": "alias" }` is replaced with the imported recipe's command-template graph. Imported recipe defaults are merged with import `defaults`, import `values`, node `defaults`, and node `values`; later layers win. This lets a parent recipe embed a reusable recipe in a sequence or `parallel: true` branch without inventing a workflow language.
231
+
232
+ Async composition stays explicit: importing a recipe reuses its command-template-shaped definition. It does not start a nested async run. Put `async: true` on the parent recipe when the combined imported graph should run detached as one run with one state dir. For agent-callable fanout, prefer public inputs such as `prompts:array` plus `repeat: "{prompts.length}"`, then select each branch value with `{prompts[index]}` instead of baking concrete prompts or file names into the reusable recipe.
233
+
234
+ ```json
235
+ {
236
+ "name": "parallel-review",
237
+ "async": true,
238
+ "imports": {
239
+ "review": "review-one.json"
240
+ },
241
+ "parallel": true,
242
+ "failure": "branch",
243
+ "template": [
244
+ { "name": "review", "values": { "scope": "README.md" } },
245
+ { "name": "review", "values": { "scope": "docs/template-recipes.md" } }
246
+ ]
247
+ }
248
+ ```
249
+
250
+ Recipes can also read imported metadata and value containers before command-template placeholder expansion. Each import alias acts like a recipe-local variable:
251
+
252
+ ```json
253
+ {
254
+ "imports": {
255
+ "base": {
256
+ "from": "base.json",
257
+ "values": { "target": "docs" }
258
+ }
259
+ },
260
+ "defaults": {
261
+ "profile": "{base.defaults.profile=safe}",
262
+ "target": "{base.values.target}",
263
+ "label": "{base.name}:{base.values.target}",
264
+ "enabled_label": "{base.defaults.enabled?enabled:disabled}"
265
+ },
266
+ "template": "run {base.defaults.profile=safe} {base.values.target} {label}"
267
+ }
268
+ ```
269
+
270
+ Supported references are:
271
+
272
+ - `{alias.name}`
273
+ - `{alias.file}`
274
+ - `{alias.defaults.key}`
275
+ - `{alias.values.key}`
276
+ - `{alias.defaults.key=fallback}` for a missing/null import value fallback.
277
+ - `{alias.values.key?truthy:falsy}` for a small recipe-layer ternary.
278
+
279
+ Nested object keys are dot-separated. Import references are resolved before normal command-template placeholders, so ordinary values such as `{label}` still flow through command-template defaults and call-time values. Ternaries use simple falsy checks for missing, null, false, zero, and empty string. Missing imports, missing values without fallback, and import cycles fail during recipe loading.
280
+
281
+ ## Recipe Shape
282
+
283
+ Use `name` for an explicit recipe id, rely on 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.
284
+
285
+ 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.
@@ -0,0 +1,142 @@
1
+ # Tool Registry
2
+
3
+ `pi-actors` stores registered command-template tools and template-recipe launchers in `~/.pi/agent/tools.json` and registers them automatically on session start.
4
+
5
+ This document is the local adaptation of the portable [Command Template Standard](./command-templates.md).
6
+
7
+ ## Registering Tools
8
+
9
+ `register_tool` is the interactive API for listing, creating, updating, or deleting persistent tools. Call it without arguments to list registered tools.
10
+
11
+ ```text
12
+ register_tool name=transcribe_groq \
13
+ description="Transcribe audio files using Groq Whisper API" \
14
+ template="~/.pi/agent/skills/groq-stt/scripts/transcribe.mjs {file} {lang=ru} {model=whisper-large-v3-turbo}"
15
+ ```
16
+
17
+ ```text
18
+ register_tool name=call_subagent \
19
+ description="Run pi as a non-interactive sub-agent" \
20
+ template="pi -p --model {model=openai-codex/gpt-5.5} --no-tools {prompt}"
21
+ ```
22
+
23
+ Use `update=true` to overwrite an existing tool. Omit `template` and co-located recipe fields during update to keep the previous execution binding.
24
+
25
+ `template` may also be a standard command-template sequence for multi-step tools. Timeout is disabled by default; add explicit positive `timeout` values when individual steps should fail closed:
26
+
27
+ ```json
28
+ [
29
+ "~/bin/tts --text {text} --out {mp3}",
30
+ { "timeout": 300000, "template": "ffmpeg -y -i {mp3} -c:a libopus {ogg}" }
31
+ ]
32
+ ```
33
+
34
+ For reusable workflows, register a small tool whose `template` points to a template recipe instead of embedding a large parallel template in the tool itself:
35
+
36
+ ```text
37
+ register_tool name=shader_ring \
38
+ description="Start the shader ring recipe" \
39
+ template="shader-ring-8-parallel.json" \
40
+ args="theme,out_dir"
41
+ ```
42
+
43
+ This stores the recipe path in the registry as `template`. If `~/.pi/agent/recipes/shader-ring-8-parallel.json` contains `async: true`, calling the tool starts a detached run and returns metadata immediately. If `async` is omitted or false, the same recipe runs foreground and returns normal tool output.
44
+
45
+ When co-location is clearer than a separate file, the registry entry may include recipe fields directly beside tool metadata:
46
+
47
+ ```json
48
+ {
49
+ "review_docs": {
50
+ "description": "Start an async docs review",
51
+ "name": "review-docs",
52
+ "async": true,
53
+ "template": "pi -p --model openai-codex/gpt-5.5 --tools read,bash \"Review {scope}\""
54
+ }
55
+ }
56
+ ```
57
+
58
+ This is still not a cycle: `name` names the saved definition when it differs from the tool key, `async: true` selects detached run mode, and `template` remains the executable body. Co-located recipe entries must not define `tool`.
59
+
60
+ Delete a tool with `template=null`:
61
+
62
+ ```text
63
+ register_tool name=call_subagent template=null
64
+ ```
65
+
66
+ ## Stored Shape
67
+
68
+ Tool names come from the top-level registry keys. Tool entries define `template`; it may be an inline command template, a template recipe JSON path/name, or the body of a co-located template recipe when `async` or entry-local `name` is present. Template entries keep `template` last, matching the command-template readability rule. The commands above persist entries like this:
69
+
70
+ ```json
71
+ {
72
+ "transcribe_groq": {
73
+ "description": "Transcribe audio files using Groq Whisper API",
74
+ "template": "~/.pi/agent/skills/groq-stt/scripts/transcribe.mjs {file} {lang=ru} {model=whisper-large-v3-turbo}"
75
+ },
76
+ "call_subagent": {
77
+ "description": "Run pi as a non-interactive sub-agent",
78
+ "template": "pi -p --model {model=openai-codex/gpt-5.5} --no-tools {prompt}"
79
+ },
80
+ "shader_ring": {
81
+ "description": "Start the shader ring recipe",
82
+ "args": ["theme", "out_dir"],
83
+ "template": "shader-ring-8-parallel.json"
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## Args and Defaults
89
+
90
+ When `args` is omitted, `pi-actors` derives tool parameters from placeholders in `template`:
91
+
92
+ ```text
93
+ template="~/bin/transcribe {file} {lang=ru} {model=whisper-large-v3-turbo}"
94
+ ```
95
+
96
+ The optional `args` field is an explicit placeholder declaration, matching the command-template standard. Untyped declarations remain valid:
97
+
98
+ ```json
99
+ { "args": ["file", "lang"] }
100
+ ```
101
+
102
+ Typed declarations are progressive and compact; they improve generated tool schemas and runtime validation without requiring authors to write JSON Schema. Types can be declared either in `args` or directly on template placeholders.
103
+
104
+ Use the metadata-first style when the command line is long and readability benefits from keeping the executable string short:
105
+
106
+ ```json
107
+ {
108
+ "args": [
109
+ "file:path",
110
+ "out_dir:path",
111
+ "request_timeout:int",
112
+ "speed:number",
113
+ "dry_run:bool",
114
+ "mode:enum(check,fix)"
115
+ ],
116
+ "defaults": {
117
+ "timeout": "60000",
118
+ "speed": "1.5",
119
+ "dry_run": "true",
120
+ "mode": "check"
121
+ },
122
+ "template": "tool --file {file} --out {out_dir} --timeout {request_timeout} --speed {speed} --dry-run {dry_run} --mode {mode}"
123
+ }
124
+ ```
125
+
126
+ Use the inline-first style when a compact tool is clearer as one self-contained template:
127
+
128
+ ```text
129
+ template="tool --file {file:path} --out {out_dir:path} --timeout {request_timeout:int=60000} --speed {speed:number=1.5} --dry-run {dry_run:bool=true} --mode {mode:enum(check,fix)=check}"
130
+ ```
131
+
132
+ Supported compact types are `string` (implicit), `path`, `int`, `number`, `bool`, and `enum(a,b)`. Defaults should be stored in `defaults`, written inline as `{name=default}`, or supplied through interactive shorthand. Shorthand such as `args="file,lang=ru"` and typed shorthand such as `request_timeout:int=60000` are normalized before persistence. When both `args` and template placeholders provide a type for the same name, explicit `args` wins.
133
+
134
+ Defaults are applied before substitution, with resolution order runtime values → stored `defaults` → inline default → error. Missing required values are rejected before or during execution. Typed runtime values are normalized before substitution: `int` and `number` values become numeric strings, booleans become `true`/`false`, and enums must match one of the declared values.
135
+
136
+ Template recipe tools derive public arguments from the referenced or co-located command template when the recipe is available locally. Explicit `args` is still available when the public tool surface should be narrower or defaulted differently, or when a file-backed recipe is not available during registration. Runtime values are passed as `values`; async recipe tools also accept optional `run_id` to override the generated run id.
137
+
138
+ ## File Argument Naming
139
+
140
+ For tools that accept a local file path, use `file` as the canonical argument name.
141
+
142
+ Avoid using `filename` for full paths. `filename` usually means a basename/display name, while `file` can represent a concrete local file path.
package/index.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * pi-actors — actor runtime and persistent local tool registry for pi.
3
+ * Zones: composition root, pi agent, actor runtime
4
+ *
5
+ * Wraps command templates as callable pi tools, stores their definitions in tools.json, and exposes actor orchestration across reloads and sessions.
6
+ */
7
+
8
+ import { existsSync, readdirSync, watch, type FSWatcher } from "node:fs";
9
+
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ } from "@earendil-works/pi-coding-agent";
14
+
15
+ import * as CommandTemplates from "./lib/command-templates.ts";
16
+ import * as Observability from "./lib/observability.ts";
17
+ import * as Paths from "./lib/paths.ts";
18
+ import * as Prompts from "./lib/prompts.ts";
19
+ import * as Runtime from "./lib/runtime.ts";
20
+ import * as Temp from "./lib/temp.ts";
21
+ import * as Tools from "./lib/tools.ts";
22
+
23
+ const CONFIG_PATH = Paths.getConfigPath();
24
+ const TEMP_DIR = Paths.getExtensionTmpDir();
25
+ const RUN_STATE_ROOT = Paths.getRunStateRoot();
26
+ const RESERVED_TOOL_NAMES = new Set([
27
+ "read",
28
+ "write",
29
+ "edit",
30
+ "bash",
31
+ "find",
32
+ "grep",
33
+ "ls",
34
+ "register_tool",
35
+ "message",
36
+ "spawn",
37
+ "inspect",
38
+ ]);
39
+
40
+ export default function toolRegistryExtension(pi: ExtensionAPI) {
41
+ let runsAnimationInterval: NodeJS.Timeout | undefined;
42
+ let runsNotifyTimeout: NodeJS.Timeout | undefined;
43
+ let stateRootWatcher: FSWatcher | undefined;
44
+ const runDirWatchers = new Map<string, FSWatcher>();
45
+ const observedRuns = new Map<string, Observability.RunObservedStatus>();
46
+ const observedRunEventLines = new Map<string, number>();
47
+ let runStatusFrame = 0;
48
+ const getRunOwnerId = (ctx: ExtensionContext): string =>
49
+ ctx.sessionManager.getSessionId();
50
+ const updateRunUi = (ctx: ExtensionContext, notify = false): void => {
51
+ const ownerId = getRunOwnerId(ctx);
52
+ const summary = Observability.summarizeRuns(undefined, ownerId);
53
+ const status = Observability.renderRunStatus(summary, runStatusFrame++);
54
+ ctx.ui.setStatus(
55
+ "zz-pi-actors-runs",
56
+ status ? ctx.ui.theme.fg("dim", status) : undefined,
57
+ );
58
+ ctx.ui.setWidget("zz-pi-actors-runs", undefined);
59
+ const transitions = Observability.detectRunTransitions(
60
+ observedRuns,
61
+ summary,
62
+ );
63
+ const outboxEvents = Observability.detectRunOutboxEvents(
64
+ observedRunEventLines,
65
+ summary,
66
+ );
67
+ if (!notify) return;
68
+ for (const transition of transitions) {
69
+ if (!Observability.shouldNotifyRunTransition(transition)) continue;
70
+ const text = Observability.formatRunTransitionMessage(transition);
71
+ const notificationType =
72
+ Observability.getRunTransitionNotificationType(transition);
73
+ ctx.ui.notify(text, notificationType);
74
+ if (!Observability.shouldSendRunTransitionFollowUp(transition)) continue;
75
+ pi.sendMessage(
76
+ {
77
+ customType: "pi-actors-run",
78
+ content: text,
79
+ display: true,
80
+ details: transition,
81
+ },
82
+ { deliverAs: "followUp", triggerTurn: true },
83
+ );
84
+ }
85
+ for (const event of outboxEvents) {
86
+ if (!Observability.shouldNotifyRunOutboxEvent(event)) continue;
87
+ const text = Observability.formatRunOutboxMessage(event);
88
+ const notificationType =
89
+ Observability.getRunOutboxNotificationType(event);
90
+ ctx.ui.notify(text, notificationType);
91
+ if (!Observability.shouldSendRunOutboxFollowUp(event)) continue;
92
+ pi.sendMessage(
93
+ {
94
+ customType: "pi-actors-run-message",
95
+ content: text,
96
+ display: true,
97
+ details: event,
98
+ },
99
+ { deliverAs: "followUp", triggerTurn: true },
100
+ );
101
+ }
102
+ };
103
+ const closeRunWatchers = (): void => {
104
+ stateRootWatcher?.close();
105
+ stateRootWatcher = undefined;
106
+ for (const watcher of runDirWatchers.values()) watcher.close();
107
+ runDirWatchers.clear();
108
+ if (runsNotifyTimeout) clearTimeout(runsNotifyTimeout);
109
+ runsNotifyTimeout = undefined;
110
+ };
111
+ const scheduleRunEventUpdate = (ctx: ExtensionContext): void => {
112
+ if (runsNotifyTimeout) clearTimeout(runsNotifyTimeout);
113
+ runsNotifyTimeout = setTimeout(() => {
114
+ refreshRunWatchers(ctx);
115
+ updateRunUi(ctx, true);
116
+ }, 50);
117
+ runsNotifyTimeout.unref?.();
118
+ };
119
+ const watchRunDir = (ctx: ExtensionContext, stateDir: string): void => {
120
+ if (runDirWatchers.has(stateDir) || !existsSync(stateDir)) return;
121
+ try {
122
+ const watcher = watch(stateDir, () => scheduleRunEventUpdate(ctx));
123
+ watcher.on("error", () => {
124
+ watcher.close();
125
+ runDirWatchers.delete(stateDir);
126
+ });
127
+ runDirWatchers.set(stateDir, watcher);
128
+ } catch {
129
+ // Watching is best-effort; explicit inspect remains available.
130
+ }
131
+ };
132
+ function refreshRunWatchers(ctx: ExtensionContext): void {
133
+ if (!existsSync(RUN_STATE_ROOT)) return;
134
+ if (!stateRootWatcher) {
135
+ try {
136
+ stateRootWatcher = watch(RUN_STATE_ROOT, () => scheduleRunEventUpdate(ctx));
137
+ stateRootWatcher.on("error", () => {
138
+ stateRootWatcher?.close();
139
+ stateRootWatcher = undefined;
140
+ });
141
+ } catch {
142
+ // Watching is best-effort; explicit inspect remains available.
143
+ }
144
+ }
145
+ for (const entry of readdirSync(RUN_STATE_ROOT, { withFileTypes: true })) {
146
+ if (!entry.isDirectory()) continue;
147
+ watchRunDir(ctx, `${RUN_STATE_ROOT}/${entry.name}`);
148
+ }
149
+ }
150
+ const actorToolDefinitions = new Map<string, any>();
151
+ const runtime = Runtime.createAutoToolsRuntime({
152
+ configPath: CONFIG_PATH,
153
+ exec: CommandTemplates.execCommandTemplate,
154
+ getAllTools: () => pi.getAllTools(),
155
+ registerTool: (definition) => {
156
+ actorToolDefinitions.set(definition.name, definition);
157
+ pi.registerTool(definition);
158
+ },
159
+ reservedToolNames: RESERVED_TOOL_NAMES,
160
+ });
161
+ pi.on("session_start", async (_event, ctx) => {
162
+ await Temp.prepareExtensionTempDir(TEMP_DIR);
163
+ runtime.loadTools(ctx);
164
+ updateRunUi(ctx);
165
+ closeRunWatchers();
166
+ refreshRunWatchers(ctx);
167
+ if (runsAnimationInterval) clearInterval(runsAnimationInterval);
168
+ runsAnimationInterval = setInterval(() => updateRunUi(ctx, false), 1000);
169
+ runsAnimationInterval.unref?.();
170
+ });
171
+ pi.on("session_shutdown", async () => {
172
+ if (runsAnimationInterval) clearInterval(runsAnimationInterval);
173
+ runsAnimationInterval = undefined;
174
+ closeRunWatchers();
175
+ });
176
+ pi.on("before_agent_start", async (event) => ({
177
+ systemPrompt: `${event.systemPrompt}\n\n${Prompts.ONBOARDING_SYSTEM_PROMPT}`,
178
+ }));
179
+ pi.registerTool(
180
+ Tools.createRegisterToolDefinition<ExtensionContext>({
181
+ configPath: CONFIG_PATH,
182
+ getActiveTools: () => pi.getActiveTools(),
183
+ getExternalToolConflict: runtime.getExternalToolConflict,
184
+ getTools: runtime.getTools,
185
+ notify: runtime.notify,
186
+ registerRuntimeTool: runtime.registerRuntimeTool,
187
+ reservedToolNames: RESERVED_TOOL_NAMES,
188
+ setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
189
+ }),
190
+ );
191
+ pi.registerTool(Tools.createSpawnToolDefinition<ExtensionContext>());
192
+ pi.registerTool(
193
+ Tools.createActorMessageToolDefinition<ExtensionContext>({
194
+ getTool: (name) => actorToolDefinitions.get(name),
195
+ }),
196
+ );
197
+ pi.registerTool(Tools.createInspectToolDefinition());
198
+ }