@juicesharp/rpiv-args 1.9.2 → 1.10.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/README.md +42 -0
- package/args.ts +230 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,6 +190,48 @@ backward compatibility.
|
|
|
190
190
|
- Skills **with** placeholders → body gets substitution, raw args still appended after block
|
|
191
191
|
- The `argument-hint` frontmatter field is read but not enforced in v1
|
|
192
192
|
|
|
193
|
+
## Variables and shell execution
|
|
194
|
+
|
|
195
|
+
Skills can reference runtime context and inline shell command output. These run on **every** invocation, regardless of whether the skill body uses `$N` / `$ARGUMENTS` tokens.
|
|
196
|
+
|
|
197
|
+
| Syntax | Replaced with |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `${SKILL_DIR}` | Absolute path to the skill's source directory (forward-slash normalized on Windows) |
|
|
200
|
+
| `${SESSION_ID}` | The current Pi session id |
|
|
201
|
+
| `` !`command` `` | Single-line shell command output (no newline crossing) |
|
|
202
|
+
| ` ```!\n…\n``` ` | Multi-line shell program output (newlines preserved) |
|
|
203
|
+
|
|
204
|
+
### Shell execution semantics
|
|
205
|
+
|
|
206
|
+
- **Working directory**: every shell command runs in `process.cwd()` (the Pi session's working directory).
|
|
207
|
+
- **Sequential**: commands within one body run one at a time, in source order. `` !`mkdir x` `` then `` !`ls x` `` is safe.
|
|
208
|
+
- **Output truncation**: combined stdout + stderr capped at 50 KB / 2000 lines (tail-truncated — failures at the end of the output survive).
|
|
209
|
+
- **Errors are inlined** (the rest of the body still reaches the LLM):
|
|
210
|
+
- Timeout → `[Shell error: timed out after Ns]`
|
|
211
|
+
- Non-zero exit → `[Shell error: exit code N]\n<stderr>`
|
|
212
|
+
- **`shell-timeout` frontmatter** (seconds, default 120 s):
|
|
213
|
+
|
|
214
|
+
| Value | Effect |
|
|
215
|
+
|---|---|
|
|
216
|
+
| absent | 120 s (default) |
|
|
217
|
+
| positive number (e.g. `5`, `0.5`) | converted to ms |
|
|
218
|
+
| `0` | timer disabled (no timeout) |
|
|
219
|
+
| any non-finite or negative value (string, `NaN`, `.inf`, `-1`, `true`) | silent fallback to default 120 s |
|
|
220
|
+
|
|
221
|
+
### Cross-platform skill authoring
|
|
222
|
+
|
|
223
|
+
On Windows, rpiv-args runs each command via `powershell.exe -Command` (PowerShell 5.1+ ships with every supported Windows version). On macOS / Linux it uses `sh -c`. Most POSIX utilities work on both platforms because PowerShell exposes them as aliases:
|
|
224
|
+
|
|
225
|
+
| POSIX command | Works on Windows via PowerShell alias |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `ls`, `cat`, `pwd`, `cp`, `mv`, `rm`, `mkdir` | ✅ (aliases of `Get-ChildItem`, `Get-Content`, etc.) |
|
|
228
|
+
| `git`, `npm`, `node`, `python` | ✅ (external binaries on PATH) |
|
|
229
|
+
| `grep`, `sed`, `awk`, `find`, `xargs` | ❌ (not aliased — use PowerShell equivalents like `Select-String`) |
|
|
230
|
+
|
|
231
|
+
> **POSIX flags are NOT translated.** Aliases match command NAMES only. `` !`rm -rf x` `` will FAIL under PowerShell because `Remove-Item` takes `-Recurse -Force`, not `-rf`. For destructive or flag-heavy commands, prefer external binaries (`git`, `npm`, `node`) or write a portable PowerShell-flavored block (`` ```! ``` ``) instead.
|
|
232
|
+
|
|
233
|
+
**PowerShell cmdlet exit-code quirk**: external commands propagate their exit code via `$LASTEXITCODE`, which PowerShell reflects in its own exit code (so `` !`git status` `` reports failure correctly). However, **cmdlet errors return exit 0 by default**. If a skill relies on a cmdlet's failure to be visible, prepend `$ErrorActionPreference = "Stop"; ` or use `-ErrorAction Stop` per cmdlet. For maximum portability, prefer external commands (`git`, `npm`) over cmdlets where you care about exit codes.
|
|
234
|
+
|
|
193
235
|
## Limitations
|
|
194
236
|
|
|
195
237
|
| Limitation | Detail |
|
package/args.ts
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
* rpiv-args — core logic.
|
|
3
3
|
*
|
|
4
4
|
* Intercepts `/skill:<name> <args>` at the input hook and emits a Pi skill
|
|
5
|
-
* wrapper
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
5
|
+
* wrapper. Pipeline (FR9):
|
|
6
|
+
* strip frontmatter → $N/$ARGUMENTS substitution (opt-in via TOKEN_REGEX)
|
|
7
|
+
* → ${SKILL_DIR}/${SESSION_ID} substitution (always-on, FR10)
|
|
8
|
+
* → shell execution (always-on, FR10 — see executeShellInBody)
|
|
9
|
+
* → wrap in <skill name=… location=…>…</skill> block
|
|
10
|
+
*
|
|
11
|
+
* Emit-path divergence (FR12): the trailing `\n\n${args}` suffix policy is
|
|
12
|
+
* governed by ORIGINAL token presence (`hadTokens`). The no-token path emits
|
|
13
|
+
* byte-identical to Pi's built-in `_expandSkillCommand`; the token path
|
|
14
|
+
* intentionally drops the suffix (substitution consumed the args; bare
|
|
15
|
+
* trailing imperatives hijack LLM attention from the skill body).
|
|
16
|
+
*
|
|
17
|
+
* Variable substitution and shell execution always run on BOTH emit paths —
|
|
18
|
+
* `hadTokens` governs the suffix only, not the substitution pipeline.
|
|
16
19
|
*
|
|
17
20
|
* Also prepends a skill-invocation protocol to the system prompt every turn
|
|
18
21
|
* (via before_agent_start) so the LLM treats trailing text after `</skill>`
|
|
@@ -30,7 +33,12 @@ import { dirname, join, resolve } from "node:path";
|
|
|
30
33
|
import {
|
|
31
34
|
type BeforeAgentStartEvent,
|
|
32
35
|
type BeforeAgentStartEventResult,
|
|
36
|
+
DEFAULT_MAX_BYTES,
|
|
37
|
+
DEFAULT_MAX_LINES,
|
|
38
|
+
type ExecResult,
|
|
33
39
|
type ExtensionAPI,
|
|
40
|
+
type ExtensionContext,
|
|
41
|
+
formatSize,
|
|
34
42
|
getAgentDir,
|
|
35
43
|
type InputEvent,
|
|
36
44
|
type InputEventResult,
|
|
@@ -38,6 +46,8 @@ import {
|
|
|
38
46
|
parseFrontmatter,
|
|
39
47
|
type Skill,
|
|
40
48
|
stripFrontmatter,
|
|
49
|
+
type TruncationResult,
|
|
50
|
+
truncateTail,
|
|
41
51
|
} from "@earendil-works/pi-coding-agent";
|
|
42
52
|
|
|
43
53
|
// ---------------------------------------------------------------------------
|
|
@@ -45,7 +55,8 @@ import {
|
|
|
45
55
|
// ---------------------------------------------------------------------------
|
|
46
56
|
|
|
47
57
|
/** Matches any placeholder Pi's substituteArgs would replace. Used as the
|
|
48
|
-
* opt-in gate
|
|
58
|
+
* opt-in gate for the $N/$ARGUMENTS substitution path AND as the
|
|
59
|
+
* emit-path flag (hadTokens) governing the trailing-args suffix. */
|
|
49
60
|
const TOKEN_REGEX = /\$(?:\d+|ARGUMENTS|@|\{@:\d+(?::\d+)?\})/;
|
|
50
61
|
|
|
51
62
|
/** Prefix Pi uses (`agent-session.js:829`). Single-space tokenisation. */
|
|
@@ -54,6 +65,27 @@ const SKILL_PREFIX = "/skill:";
|
|
|
54
65
|
/** Re-entrancy guard. */
|
|
55
66
|
const WRAPPED_PREFIX = "<skill ";
|
|
56
67
|
|
|
68
|
+
/** Default ceiling for shell execution: 2 minutes. Frontmatter `shell-timeout`
|
|
69
|
+
* (seconds) overrides; `0` disables natively via pi.exec's `&&` short-circuit
|
|
70
|
+
* at dist/core/exec.js:42. */
|
|
71
|
+
const DEFAULT_SHELL_TIMEOUT_MS = 120_000;
|
|
72
|
+
|
|
73
|
+
/** Inline shell: !`command` — non-greedy single-line, no newline crossing.
|
|
74
|
+
* Capture is `[^`\n]+` (at least one char) so a literal `` !`` `` in
|
|
75
|
+
* prose does NOT run the shell with an empty `-c` argument (per
|
|
76
|
+
* artifact-reviewer finding R3).
|
|
77
|
+
* Runs AFTER block; block-before-inline is enforced by the mask-and-restore
|
|
78
|
+
* pass below (block outputs are protected from inline re-execution per R2).
|
|
79
|
+
* MUST stay /g — consumed by matchAll(); do NOT call .exec()/.test() on
|
|
80
|
+
* this directly (stale lastIndex would silently skip matches). */
|
|
81
|
+
const SHELL_INLINE_PATTERN = /!`([^`\n]+)`/g;
|
|
82
|
+
|
|
83
|
+
/** Block shell: ```!\n…\n``` — multiline non-greedy. Captured content is
|
|
84
|
+
* handed to the shell as a single program (newlines preserved).
|
|
85
|
+
* MUST stay /g — consumed by matchAll(); do NOT call .exec()/.test() on
|
|
86
|
+
* this directly (stale lastIndex would silently skip matches). */
|
|
87
|
+
const SHELL_BLOCK_PATTERN = /```!\n([\s\S]*?)\n```/g;
|
|
88
|
+
|
|
57
89
|
// ---------------------------------------------------------------------------
|
|
58
90
|
// Tokeniser — byte-equivalent to Pi's parseCommandArgs at
|
|
59
91
|
// node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js:11-42
|
|
@@ -110,6 +142,163 @@ export function substituteArgs(content: string, args: string[]): string {
|
|
|
110
142
|
return result;
|
|
111
143
|
}
|
|
112
144
|
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Variable substitution — mechanical, runs after $N/$ARGUMENTS and before
|
|
147
|
+
// shell execution. ${SKILL_DIR} is forward-slash-normalized at the
|
|
148
|
+
// substitution site ONLY — buildSkillBlock must stay byte-exact (its
|
|
149
|
+
// `References are relative to ${baseDir}` line is consumed by Pi unchanged).
|
|
150
|
+
//
|
|
151
|
+
// Backslash normalization is GATED on process.platform === "win32" so a
|
|
152
|
+
// POSIX path containing a literal backslash (e.g. `/tmp/weird\name`) is
|
|
153
|
+
// byte-preserving. Per artifact-reviewer finding R7.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export function substituteVariables(body: string, vars: { skillDir: string; sessionId: string }): string {
|
|
157
|
+
const skillDir = process.platform === "win32" ? vars.skillDir.split("\\").join("/") : vars.skillDir;
|
|
158
|
+
return body.replace(/\$\{SKILL_DIR\}/g, skillDir).replace(/\$\{SESSION_ID\}/g, vars.sessionId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// shell-timeout resolution.
|
|
163
|
+
//
|
|
164
|
+
// YAML scalar coercion at frontmatter parse time can produce any of: number,
|
|
165
|
+
// string, boolean, null, NaN (from `.nan`), Infinity (from `.inf`). Silent
|
|
166
|
+
// fallback to default on any non-finite or non-positive value matches Pi's
|
|
167
|
+
// graceful-degradation posture at dist/utils/frontmatter.js:24 (`parsed ?? {}`).
|
|
168
|
+
//
|
|
169
|
+
// Number.isFinite is load-bearing — both NaN and Infinity must be rejected:
|
|
170
|
+
// - NaN → would silently bypass exec.js:42's `&& options.timeout > 0`
|
|
171
|
+
// short-circuit (NaN > 0 is false) and disable the timer, hiding
|
|
172
|
+
// an FR4 violation.
|
|
173
|
+
// - Infinity → Node's setTimeout(fn, Infinity) clamps to 1ms → an immediate
|
|
174
|
+
// kill (the opposite of "no timeout").
|
|
175
|
+
//
|
|
176
|
+
// `0` is honored as explicit disable (FR4).
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export function resolveShellTimeoutMs(frontmatter: { "shell-timeout"?: unknown }): number {
|
|
180
|
+
const raw = frontmatter["shell-timeout"];
|
|
181
|
+
if (raw === undefined) return DEFAULT_SHELL_TIMEOUT_MS;
|
|
182
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_SHELL_TIMEOUT_MS;
|
|
183
|
+
if (raw < 0) return DEFAULT_SHELL_TIMEOUT_MS;
|
|
184
|
+
if (raw === 0) return 0;
|
|
185
|
+
return raw * 1000;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Shell execution.
|
|
190
|
+
//
|
|
191
|
+
// Pipeline: blocks first, then inlines. Block-before-inline is load-bearing
|
|
192
|
+
// — the block pattern's content group `[\s\S]*?` legitimately matches `!\``
|
|
193
|
+
// inside the fence; running inline first would eat backticks from block
|
|
194
|
+
// content and produce malformed bodies.
|
|
195
|
+
//
|
|
196
|
+
// Sequential iteration (FR11) — never Promise.all. Skill authors rely on
|
|
197
|
+
// `!`mkdir x`` → `!`ls x`` ordering. The git-context.ts:36-44 Promise.all
|
|
198
|
+
// precedent for parallel read-only git commands is INTENTIONALLY not copied.
|
|
199
|
+
//
|
|
200
|
+
// Cross-platform shim: PowerShell on Windows (POSIX-alias coverage), sh on
|
|
201
|
+
// POSIX. `pi.exec` uses spawn(…, {shell:false}) per dist/core/exec.js:13-17
|
|
202
|
+
// so the shim is required.
|
|
203
|
+
//
|
|
204
|
+
// `pi.exec` NEVER rejects (dist/core/exec.js:10-72 — every termination path
|
|
205
|
+
// calls resolve(...)). No try/catch needed here.
|
|
206
|
+
//
|
|
207
|
+
// FR5 branch order: killed → code !== 0 → success. `killed` is checked first
|
|
208
|
+
// because a timed-out child may also report a non-zero code via `code ?? 0`
|
|
209
|
+
// (exec.js:60) or `1` via the catch (exec.js:71); the timeout message wins.
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** Truncate a string for LLM consumption: 50KB / 2000-line tail budget,
|
|
213
|
+
* with a `[truncated: hit ...]` footer when truncation occurred. Shared
|
|
214
|
+
* by the success path (`formatShellOutput`) and the non-zero exit path
|
|
215
|
+
* in `runOneShellCommand` so a multi-MB stderr from a failed `!`npm test``
|
|
216
|
+
* cannot bypass FR2's budget (per R1). */
|
|
217
|
+
function truncateForLLM(content: string): string {
|
|
218
|
+
const trunc: TruncationResult = truncateTail(content, {
|
|
219
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
220
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
221
|
+
});
|
|
222
|
+
let out = trunc.content;
|
|
223
|
+
if (trunc.truncated) {
|
|
224
|
+
const limit = trunc.truncatedBy === "lines" ? `${trunc.maxLines} lines` : formatSize(trunc.maxBytes);
|
|
225
|
+
out += `\n[truncated: hit ${limit}]`;
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatShellOutput(res: ExecResult): string {
|
|
231
|
+
let combined = res.stdout;
|
|
232
|
+
if (res.stderr && res.stderr.length > 0) {
|
|
233
|
+
const sep = combined.length === 0 || combined.endsWith("\n") ? "" : "\n";
|
|
234
|
+
combined = `${combined}${sep}[stderr]\n${res.stderr}`;
|
|
235
|
+
}
|
|
236
|
+
return truncateForLLM(combined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function runOneShellCommand(command: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise<string> {
|
|
240
|
+
const [shCmd, shFlag] = process.platform === "win32" ? ["powershell.exe", "-Command"] : ["sh", "-c"];
|
|
241
|
+
const res: ExecResult = await pi.exec(shCmd, [shFlag, command], { cwd, timeout: timeoutMs });
|
|
242
|
+
if (res.killed) {
|
|
243
|
+
// Floor at 1s so sub-second `shell-timeout` values (e.g. 0.5) don't display
|
|
244
|
+
// the contradictory `[Shell error: timed out after 0s]` (per R4).
|
|
245
|
+
const sec = Math.max(1, Math.round(timeoutMs / 1000));
|
|
246
|
+
return `[Shell error: timed out after ${sec}s]`;
|
|
247
|
+
}
|
|
248
|
+
if (res.code !== 0) {
|
|
249
|
+
// FR2 budget on the error path — stderr is truncated identically to
|
|
250
|
+
// the success path so a failed `!`npm test`` cannot blow the LLM budget (R1).
|
|
251
|
+
return `[Shell error: exit code ${res.code}]\n${truncateForLLM(res.stderr)}`;
|
|
252
|
+
}
|
|
253
|
+
return formatShellOutput(res);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Mask-and-restore strategy (per R2): run the block pass first, replacing
|
|
257
|
+
* each block match with a non-printable sentinel (`\x00BLOCK${n}\x00`) that
|
|
258
|
+
* the inline regex CANNOT match (sentinels have no backticks). Then run the
|
|
259
|
+
* inline pass on the sentinel-bearing string. Finally restore sentinels to
|
|
260
|
+
* their block outputs. This guarantees block stdout containing literal
|
|
261
|
+
* `` !`...` `` is NEVER re-executed by the inline pass. */
|
|
262
|
+
export async function executeShellInBody(
|
|
263
|
+
body: string,
|
|
264
|
+
pi: ExtensionAPI,
|
|
265
|
+
cwd: string,
|
|
266
|
+
timeoutMs: number,
|
|
267
|
+
): Promise<string> {
|
|
268
|
+
// Pass 1: blocks → sentinels (outputs stashed in blockOutputs).
|
|
269
|
+
const blockOutputs: string[] = [];
|
|
270
|
+
let withSentinels = "";
|
|
271
|
+
{
|
|
272
|
+
const matches = [...body.matchAll(SHELL_BLOCK_PATTERN)];
|
|
273
|
+
let last = 0;
|
|
274
|
+
for (const m of matches) {
|
|
275
|
+
const idx = m.index ?? 0;
|
|
276
|
+
withSentinels += body.slice(last, idx);
|
|
277
|
+
withSentinels += `\x00BLOCK${blockOutputs.length}\x00`;
|
|
278
|
+
blockOutputs.push(await runOneShellCommand(m[1] ?? "", pi, cwd, timeoutMs));
|
|
279
|
+
last = idx + m[0].length;
|
|
280
|
+
}
|
|
281
|
+
withSentinels += body.slice(last);
|
|
282
|
+
}
|
|
283
|
+
// Pass 2: inlines on the sentinel-bearing string. Sentinels carry no
|
|
284
|
+
// backticks so the inline regex (which requires backticks at both ends)
|
|
285
|
+
// cannot match against them — block outputs are protected.
|
|
286
|
+
let withInlines = "";
|
|
287
|
+
{
|
|
288
|
+
const matches = [...withSentinels.matchAll(SHELL_INLINE_PATTERN)];
|
|
289
|
+
let last = 0;
|
|
290
|
+
for (const m of matches) {
|
|
291
|
+
const idx = m.index ?? 0;
|
|
292
|
+
withInlines += withSentinels.slice(last, idx);
|
|
293
|
+
withInlines += await runOneShellCommand(m[1] ?? "", pi, cwd, timeoutMs);
|
|
294
|
+
last = idx + m[0].length;
|
|
295
|
+
}
|
|
296
|
+
withInlines += withSentinels.slice(last);
|
|
297
|
+
}
|
|
298
|
+
// Pass 3: restore block sentinels to their actual outputs.
|
|
299
|
+
return withInlines.replace(/\x00BLOCK(\d+)\x00/g, (_, n) => blockOutputs[parseInt(n, 10)] ?? "");
|
|
300
|
+
}
|
|
301
|
+
|
|
113
302
|
// ---------------------------------------------------------------------------
|
|
114
303
|
// Skill-path index — populated once, refreshed on session_start(reason:reload)
|
|
115
304
|
// ---------------------------------------------------------------------------
|
|
@@ -210,10 +399,18 @@ function appendArgs(skillBlock: string, args: string): string {
|
|
|
210
399
|
}
|
|
211
400
|
|
|
212
401
|
// ---------------------------------------------------------------------------
|
|
213
|
-
// Input handler
|
|
402
|
+
// Input handler — async pipeline (FR9 ordering).
|
|
403
|
+
//
|
|
404
|
+
// `pi` is threaded as the 3rd parameter (not captured at module level) so the
|
|
405
|
+
// extension owns zero new singleton state — see architecture.md "Module-level
|
|
406
|
+
// Cache Reset". `ctx` carries the session manager for ${SESSION_ID}.
|
|
214
407
|
// ---------------------------------------------------------------------------
|
|
215
408
|
|
|
216
|
-
export function handleInput(
|
|
409
|
+
export async function handleInput(
|
|
410
|
+
event: InputEvent,
|
|
411
|
+
ctx: ExtensionContext,
|
|
412
|
+
pi: ExtensionAPI,
|
|
413
|
+
): Promise<InputEventResult> {
|
|
217
414
|
const text = event.text;
|
|
218
415
|
|
|
219
416
|
// Re-entrancy: already-wrapped text (from our own or any other
|
|
@@ -237,20 +434,24 @@ export function handleInput(event: InputEvent): InputEventResult {
|
|
|
237
434
|
return { action: "continue" }; // let Pi emit its error via _expandSkillCommand
|
|
238
435
|
}
|
|
239
436
|
|
|
240
|
-
const { frontmatter } = parseFrontmatter<{ "argument-hint"?: string }>(content);
|
|
241
|
-
void frontmatter; // informational only in v1
|
|
437
|
+
const { frontmatter } = parseFrontmatter<{ "argument-hint"?: string; "shell-timeout"?: unknown }>(content);
|
|
242
438
|
const body = stripFrontmatter(content).trim();
|
|
439
|
+
const timeoutMs = resolveShellTimeoutMs(frontmatter);
|
|
243
440
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
441
|
+
// FR12: emit-path divergence (token-path drops the trailing `\n\n${args}`
|
|
442
|
+
// suffix) is governed by ORIGINAL token presence only. FR10: variable
|
|
443
|
+
// substitution and shell execution run on BOTH paths regardless.
|
|
444
|
+
const hadTokens = TOKEN_REGEX.test(body);
|
|
445
|
+
|
|
446
|
+
let processed = hadTokens ? substituteArgs(body, parseCommandArgs(argsString)) : body;
|
|
447
|
+
processed = substituteVariables(processed, {
|
|
448
|
+
skillDir: entry.baseDir,
|
|
449
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
450
|
+
});
|
|
451
|
+
processed = await executeShellInBody(processed, pi, process.cwd(), timeoutMs);
|
|
248
452
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
// Substitution consumes the args — do not also append them after </skill>.
|
|
252
|
-
// Bare trailing imperatives hijack LLM attention from the skill body. See architecture.md.
|
|
253
|
-
return { action: "transform", text: buildSkillBlock(entry, substituted) };
|
|
453
|
+
const block = buildSkillBlock(entry, processed);
|
|
454
|
+
return { action: "transform", text: hadTokens ? block : appendArgs(block, argsString) };
|
|
254
455
|
}
|
|
255
456
|
|
|
256
457
|
// ---------------------------------------------------------------------------
|
|
@@ -274,11 +475,13 @@ export function handleBeforeAgentStart(event: BeforeAgentStartEvent): BeforeAgen
|
|
|
274
475
|
}
|
|
275
476
|
|
|
276
477
|
// ---------------------------------------------------------------------------
|
|
277
|
-
// Registration
|
|
478
|
+
// Registration. The input handler arrow forwards `ctx` (Pi's runner awaits
|
|
479
|
+
// the result at runner.js:801) and closes over `pi` so handleInput sees both
|
|
480
|
+
// without new module-level state.
|
|
278
481
|
// ---------------------------------------------------------------------------
|
|
279
482
|
|
|
280
483
|
export function registerArgsHandler(pi: ExtensionAPI): void {
|
|
281
|
-
pi.on("input", (event) => handleInput(event));
|
|
484
|
+
pi.on("input", async (event, ctx) => handleInput(event, ctx, pi));
|
|
282
485
|
pi.on("before_agent_start", (event) => handleBeforeAgentStart(event));
|
|
283
486
|
pi.on("session_start", (event) => {
|
|
284
487
|
if (event.reason === "reload" || event.reason === "startup") {
|