@naxodev/pi-apnea 0.2.0 → 0.2.2
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 +4 -0
- package/extension/commands.ts +44 -20
- package/extension/index.ts +43 -5
- package/extension/pi-role-agent.ts +398 -20
- package/package.json +4 -3
- package/skills/apnea-orchestrator/SKILL.md +5 -3
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ Run these commands inside Pi:
|
|
|
33
33
|
|
|
34
34
|
The shorter `/apnea-start` and `/apnea-status` aliases are also available. See the [Apnea CLI and operation reference](../apnea/README.md#cli-reference) for the shared command surface and exit behavior.
|
|
35
35
|
|
|
36
|
+
`/apnea abandon` previews ownership before any archival. Its confirmation, pane-close requests,
|
|
37
|
+
stopped-work attestation, and corrupt-state acknowledgment use the same flags and behavior as the CLI.
|
|
38
|
+
See [abandon confirmation](../apnea/README.md#abandon-confirmation). Abandon is not a model-facing tool.
|
|
39
|
+
|
|
36
40
|
## Verify
|
|
37
41
|
|
|
38
42
|
Run `/apnea status` before starting a workflow. A clean installation reports no active run and identifies `workflow_start` as a legal next operation. If Pi does not register the commands, run `/reload` and inspect Pi's package-loading output.
|
package/extension/commands.ts
CHANGED
|
@@ -6,8 +6,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
|
6
6
|
import {
|
|
7
7
|
DISPATCH_KINDS,
|
|
8
8
|
formatResult,
|
|
9
|
-
parseFlags,
|
|
10
9
|
parseNumFlag,
|
|
10
|
+
parseOperationArgs,
|
|
11
11
|
type DispatchKind,
|
|
12
12
|
type ExecuteOperation,
|
|
13
13
|
type Operation,
|
|
@@ -18,7 +18,6 @@ import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
|
|
|
18
18
|
export const SUBS = [
|
|
19
19
|
...PI_OPERATIONS.map((o) => o.verb),
|
|
20
20
|
"resume",
|
|
21
|
-
"abandon",
|
|
22
21
|
"help",
|
|
23
22
|
] as const satisfies readonly string[]
|
|
24
23
|
|
|
@@ -67,7 +66,7 @@ function helpText(operations: readonly Operation[]): string {
|
|
|
67
66
|
return [
|
|
68
67
|
"Apnea commands (tools remain for the model; you use /apnea):",
|
|
69
68
|
...lines,
|
|
70
|
-
" /apnea resume
|
|
69
|
+
" /apnea resume # resume an existing run",
|
|
71
70
|
` dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`,
|
|
72
71
|
" /apnea help",
|
|
73
72
|
].join("\n")
|
|
@@ -78,10 +77,14 @@ export function registerApneaCommands(
|
|
|
78
77
|
operations: readonly Operation[] = PI_OPERATIONS,
|
|
79
78
|
execute: ExecuteOperation = executePiOperation,
|
|
80
79
|
): void {
|
|
81
|
-
const run = (
|
|
80
|
+
const run = (
|
|
81
|
+
signal: AbortSignal | undefined,
|
|
82
|
+
verb: string,
|
|
83
|
+
params: Record<string, unknown>,
|
|
84
|
+
) => {
|
|
82
85
|
const operation = operations.find((candidate) => candidate.verb === verb)
|
|
83
86
|
if (!operation) throw new Error(`Missing Apnea operation: ${verb}`)
|
|
84
|
-
return execute(operation.verb, params)
|
|
87
|
+
return execute(operation.verb, params, { signal })
|
|
85
88
|
}
|
|
86
89
|
const kick = (kind: "start" | "resume", goal?: string) => {
|
|
87
90
|
pi.sendUserMessage(orchestratorKickMessage(kind, goal))
|
|
@@ -158,9 +161,16 @@ export function registerApneaCommands(
|
|
|
158
161
|
|
|
159
162
|
const tokens = raw.split(/\s+/).filter(Boolean)
|
|
160
163
|
const sub = tokens[0]!
|
|
161
|
-
const { flags, values, rest } = parseFlags(tokens.slice(1))
|
|
162
|
-
|
|
163
164
|
try {
|
|
165
|
+
const parsed = parseOperationArgs(sub, tokens.slice(1), {
|
|
166
|
+
surface: "slash",
|
|
167
|
+
})
|
|
168
|
+
if (!parsed.ok) {
|
|
169
|
+
ctx.ui.notify(parsed.message, "error")
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const { flags, values, positional: rest } = parsed
|
|
173
|
+
|
|
164
174
|
switch (sub) {
|
|
165
175
|
case "help":
|
|
166
176
|
ctx.ui.notify(helpText(operations), "info")
|
|
@@ -169,7 +179,7 @@ export function registerApneaCommands(
|
|
|
169
179
|
case "setup":
|
|
170
180
|
notify(
|
|
171
181
|
ctx,
|
|
172
|
-
await run("setup", {
|
|
182
|
+
await run(ctx.signal, "setup", {
|
|
173
183
|
project: flags.has("project"),
|
|
174
184
|
force: flags.has("force"),
|
|
175
185
|
agents_md: flags.has("agents-md"),
|
|
@@ -188,7 +198,7 @@ export function registerApneaCommands(
|
|
|
188
198
|
)
|
|
189
199
|
return
|
|
190
200
|
}
|
|
191
|
-
const r = await run("start", {
|
|
201
|
+
const r = await run(ctx.signal, "start", {
|
|
192
202
|
goal,
|
|
193
203
|
slug,
|
|
194
204
|
allow_dirty: flags.has("allow-dirty"),
|
|
@@ -200,18 +210,30 @@ export function registerApneaCommands(
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
case "resume": {
|
|
203
|
-
const r = await run("start", {
|
|
213
|
+
const r = await run(ctx.signal, "start", {
|
|
214
|
+
goal: "",
|
|
215
|
+
action: "resume",
|
|
216
|
+
})
|
|
204
217
|
notify(ctx, r)
|
|
205
218
|
if (r.ok) kick("resume")
|
|
206
219
|
return
|
|
207
220
|
}
|
|
208
221
|
|
|
209
222
|
case "abandon":
|
|
210
|
-
notify(
|
|
223
|
+
notify(
|
|
224
|
+
ctx,
|
|
225
|
+
await run(ctx.signal, "abandon", {
|
|
226
|
+
confirm: values.get("confirm"),
|
|
227
|
+
stop_panes: flags.has("stop-panes") || undefined,
|
|
228
|
+
stopped_work: flags.has("stopped-work") || undefined,
|
|
229
|
+
acknowledge_corrupt:
|
|
230
|
+
flags.has("acknowledge-corrupt") || undefined,
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
211
233
|
return
|
|
212
234
|
|
|
213
235
|
case "status":
|
|
214
|
-
notify(ctx, await run("status", {}))
|
|
236
|
+
notify(ctx, await run(ctx.signal, "status", {}))
|
|
215
237
|
return
|
|
216
238
|
|
|
217
239
|
case "wait": {
|
|
@@ -243,7 +265,7 @@ export function registerApneaCommands(
|
|
|
243
265
|
)
|
|
244
266
|
return
|
|
245
267
|
}
|
|
246
|
-
const r = await run("wait", {
|
|
268
|
+
const r = await run(ctx.signal, "wait", {
|
|
247
269
|
poll_ms: poll.value,
|
|
248
270
|
// Unbounded by default, like the Pi tool in `index.ts`:
|
|
249
271
|
// `/apnea` runs inside Pi, which has no shell timeout, so
|
|
@@ -260,16 +282,17 @@ export function registerApneaCommands(
|
|
|
260
282
|
const kind = rest[0] as DispatchKind | undefined
|
|
261
283
|
if (!kind || !DISPATCH_KINDS.includes(kind)) {
|
|
262
284
|
ctx.ui.notify(
|
|
263
|
-
`Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
|
|
285
|
+
`Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework] [--redeliver]`,
|
|
264
286
|
"error",
|
|
265
287
|
)
|
|
266
288
|
return
|
|
267
289
|
}
|
|
268
290
|
notify(
|
|
269
291
|
ctx,
|
|
270
|
-
await run("dispatch", {
|
|
292
|
+
await run(ctx.signal, "dispatch", {
|
|
271
293
|
kind,
|
|
272
294
|
rework: flags.has("rework"),
|
|
295
|
+
redeliver: flags.has("redeliver"),
|
|
273
296
|
}),
|
|
274
297
|
)
|
|
275
298
|
return
|
|
@@ -281,9 +304,9 @@ export function registerApneaCommands(
|
|
|
281
304
|
const message = rest.join(" ").trim() || undefined
|
|
282
305
|
notify(
|
|
283
306
|
ctx,
|
|
284
|
-
await run("commit", {
|
|
307
|
+
await run(ctx.signal, "commit", {
|
|
285
308
|
message,
|
|
286
|
-
no_remaining_phases: flags.has("done"),
|
|
309
|
+
no_remaining_phases: flags.has("done") || undefined,
|
|
287
310
|
}),
|
|
288
311
|
)
|
|
289
312
|
return
|
|
@@ -295,7 +318,7 @@ export function registerApneaCommands(
|
|
|
295
318
|
ctx.ui.notify("Usage: /apnea reset-rounds <gate>", "error")
|
|
296
319
|
return
|
|
297
320
|
}
|
|
298
|
-
notify(ctx, await run("reset-rounds", { gate }))
|
|
321
|
+
notify(ctx, await run(ctx.signal, "reset-rounds", { gate }))
|
|
299
322
|
return
|
|
300
323
|
}
|
|
301
324
|
|
|
@@ -314,7 +337,8 @@ export function registerApneaCommands(
|
|
|
314
337
|
// Short aliases that also show in `/` autocomplete
|
|
315
338
|
pi.registerCommand("apnea-status", {
|
|
316
339
|
description: "Apnea: read-only run status (alias of /apnea status)",
|
|
317
|
-
handler: async (_args, ctx) =>
|
|
340
|
+
handler: async (_args, ctx) =>
|
|
341
|
+
notify(ctx, await run(ctx.signal, "status", {})),
|
|
318
342
|
})
|
|
319
343
|
|
|
320
344
|
pi.registerCommand("apnea-start", {
|
|
@@ -325,7 +349,7 @@ export function registerApneaCommands(
|
|
|
325
349
|
ctx.ui.notify("Usage: /apnea-start <goal>", "error")
|
|
326
350
|
return
|
|
327
351
|
}
|
|
328
|
-
const r = await run("start", { goal, action: "start" })
|
|
352
|
+
const r = await run(ctx.signal, "start", { goal, action: "start" })
|
|
329
353
|
notify(ctx, r)
|
|
330
354
|
if (r.ok) kick("start", goal)
|
|
331
355
|
},
|
package/extension/index.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* The standalone CLI binds the same registry to argv.
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
toolContent,
|
|
10
|
+
type ExecuteOperation,
|
|
11
|
+
type Operation,
|
|
12
|
+
} from "@naxodev/apnea"
|
|
9
13
|
import { registerApneaCommands } from "./commands.ts"
|
|
10
14
|
import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
|
|
11
15
|
|
|
@@ -13,7 +17,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
13
17
|
// `/apnea …` for humans (autocomplete); tools remain for the model
|
|
14
18
|
registerApneaCommands(pi, PI_OPERATIONS, executePiOperation)
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
registerApneaTools(pi, PI_OPERATIONS, executePiOperation)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerApneaTools(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
operations: readonly Operation[],
|
|
26
|
+
executeOperation: ExecuteOperation,
|
|
27
|
+
): void {
|
|
28
|
+
for (const op of operations) {
|
|
17
29
|
if (op.tool === null) continue
|
|
18
30
|
|
|
19
31
|
// wait is the one operation with streaming + abort; Pi's exclusive.
|
|
@@ -23,6 +35,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
23
35
|
label: "Apnea wait",
|
|
24
36
|
description: [op.summary, op.guidance].filter(Boolean).join(" "),
|
|
25
37
|
parameters: op.params,
|
|
38
|
+
executionMode: "sequential",
|
|
26
39
|
async execute(
|
|
27
40
|
_id: string,
|
|
28
41
|
params: { poll_ms?: number; budget_ms?: number },
|
|
@@ -39,7 +52,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
39
52
|
// can be interrupted, so it has no host shell timeout to fit
|
|
40
53
|
// inside. The registry handler no longer injects this — only
|
|
41
54
|
// the CLI reaches that, and it must stay bounded.
|
|
42
|
-
await
|
|
55
|
+
await executeOperation(
|
|
43
56
|
op.verb,
|
|
44
57
|
{
|
|
45
58
|
...params,
|
|
@@ -70,8 +83,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
83
|
label: `Apnea ${op.verb}`,
|
|
71
84
|
description: [op.summary, op.guidance].filter(Boolean).join(" "),
|
|
72
85
|
parameters: op.params,
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
executionMode: "sequential",
|
|
87
|
+
async execute(
|
|
88
|
+
_id: string,
|
|
89
|
+
params: Record<string, unknown>,
|
|
90
|
+
signal: AbortSignal | undefined,
|
|
91
|
+
onUpdate:
|
|
92
|
+
| ((partial: {
|
|
93
|
+
content: Array<{ type: "text"; text: string }>
|
|
94
|
+
details: unknown
|
|
95
|
+
}) => void)
|
|
96
|
+
| undefined,
|
|
97
|
+
) {
|
|
98
|
+
return toolContent(
|
|
99
|
+
await executeOperation(op.verb, params, {
|
|
100
|
+
signal,
|
|
101
|
+
onUpdate: onUpdate
|
|
102
|
+
? (partial) =>
|
|
103
|
+
onUpdate({
|
|
104
|
+
content: partial.content,
|
|
105
|
+
details: {
|
|
106
|
+
ok: true,
|
|
107
|
+
message: partial.content[0]?.text ?? "",
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
: undefined,
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
75
113
|
},
|
|
76
114
|
})
|
|
77
115
|
}
|
|
@@ -4,20 +4,102 @@
|
|
|
4
4
|
* single biggest cause of idle-without-artifact stalls for the coder.
|
|
5
5
|
*
|
|
6
6
|
* Strategy: materialize a dedicated PI_CODING_AGENT_DIR that reuses the
|
|
7
|
-
* user's auth/npm/skills but filters pi-vimmode
|
|
8
|
-
* interactive `pi` launches with that env. Reused panes also get a
|
|
7
|
+
* user's auth/npm/skills but filters pi-vimmode packages and extensions,
|
|
8
|
+
* then wraps interactive `pi` launches with that env. Reused panes also get a
|
|
9
9
|
* best-effort `/vimmode off` slash command.
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs"
|
|
12
12
|
import * as os from "node:os"
|
|
13
13
|
import * as path from "node:path"
|
|
14
|
+
import { randomUUID } from "node:crypto"
|
|
15
|
+
import { fileURLToPath } from "node:url"
|
|
14
16
|
|
|
15
17
|
const PI_VIMMODE_MARKERS = ["pi-vimmode", "pekochan069/pi-vimmode"]
|
|
16
18
|
|
|
17
19
|
export function isPiCmd(cmd: string[] | undefined | null): boolean {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
return piCommandIndex(cmd) !== null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function piCommandIndex(cmd: string[] | undefined | null): number | null {
|
|
24
|
+
if (!cmd?.length) return null
|
|
25
|
+
const first = path.basename(cmd[0]!)
|
|
26
|
+
if (first === "pi") return 0
|
|
27
|
+
if (first === "bunx") {
|
|
28
|
+
let index = 1
|
|
29
|
+
while (index < cmd.length) {
|
|
30
|
+
const token = cmd[index]!
|
|
31
|
+
if (
|
|
32
|
+
token === "--bun" ||
|
|
33
|
+
token === "--no-install" ||
|
|
34
|
+
token === "--verbose" ||
|
|
35
|
+
token === "--silent"
|
|
36
|
+
) {
|
|
37
|
+
index += 1
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (token === "-p" || token === "--package") {
|
|
41
|
+
if (cmd[index + 1] === undefined) return null
|
|
42
|
+
index += 2
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (token.startsWith("--package=")) {
|
|
46
|
+
if (token.length === "--package=".length) return null
|
|
47
|
+
index += 1
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (token === "--") {
|
|
51
|
+
index += 1
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
if (token.startsWith("-")) return null
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
return path.basename(cmd[index] ?? "") === "pi" ? index : null
|
|
58
|
+
}
|
|
59
|
+
if (first !== "env") return null
|
|
60
|
+
|
|
61
|
+
let index = 1
|
|
62
|
+
while (index < cmd.length) {
|
|
63
|
+
const token = cmd[index]!
|
|
64
|
+
if (
|
|
65
|
+
token === "-i" ||
|
|
66
|
+
token === "--ignore-environment" ||
|
|
67
|
+
token === "-v" ||
|
|
68
|
+
token === "--debug"
|
|
69
|
+
) {
|
|
70
|
+
index += 1
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
token === "-u" ||
|
|
75
|
+
token === "--unset" ||
|
|
76
|
+
token === "-C" ||
|
|
77
|
+
token === "--chdir" ||
|
|
78
|
+
token === "-P" ||
|
|
79
|
+
token === "-S" ||
|
|
80
|
+
token === "--split-string"
|
|
81
|
+
) {
|
|
82
|
+
if (cmd[index + 1] === undefined) return null
|
|
83
|
+
index += 2
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (token.startsWith("--unset=")) {
|
|
87
|
+
if (token.length === "--unset=".length) return null
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (token === "--") {
|
|
92
|
+
index += 1
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
|
96
|
+
index += 1
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (token.startsWith("-")) return null
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
return path.basename(cmd[index] ?? "") === "pi" ? index : null
|
|
21
103
|
}
|
|
22
104
|
|
|
23
105
|
export function packageSource(entry: unknown): string | null {
|
|
@@ -37,6 +119,11 @@ export function isPiVimModePackage(entry: unknown): boolean {
|
|
|
37
119
|
return PI_VIMMODE_MARKERS.some((m) => lower.includes(m))
|
|
38
120
|
}
|
|
39
121
|
|
|
122
|
+
function hasVimModeMarker(value: string): boolean {
|
|
123
|
+
const lower = value.toLowerCase()
|
|
124
|
+
return PI_VIMMODE_MARKERS.some((marker) => lower.includes(marker))
|
|
125
|
+
}
|
|
126
|
+
|
|
40
127
|
/**
|
|
41
128
|
* Drop pi-vimmode from a packages list. Leaves every other entry intact
|
|
42
129
|
* (string form and object form with filters).
|
|
@@ -46,6 +133,159 @@ export function filterPackagesNoVim(packages: unknown): unknown[] {
|
|
|
46
133
|
return packages.filter((p) => !isPiVimModePackage(p))
|
|
47
134
|
}
|
|
48
135
|
|
|
136
|
+
const PACKAGE_SOURCE_KEYS = new Set([
|
|
137
|
+
"source",
|
|
138
|
+
"autoload",
|
|
139
|
+
"extensions",
|
|
140
|
+
"skills",
|
|
141
|
+
"prompts",
|
|
142
|
+
"themes",
|
|
143
|
+
])
|
|
144
|
+
const PACKAGE_RESOURCE_KEYS = [
|
|
145
|
+
"extensions",
|
|
146
|
+
"skills",
|
|
147
|
+
"prompts",
|
|
148
|
+
"themes",
|
|
149
|
+
] as const
|
|
150
|
+
|
|
151
|
+
type ValidPackageSource =
|
|
152
|
+
| string
|
|
153
|
+
| {
|
|
154
|
+
source: string
|
|
155
|
+
autoload?: boolean
|
|
156
|
+
extensions?: string[]
|
|
157
|
+
skills?: string[]
|
|
158
|
+
prompts?: string[]
|
|
159
|
+
themes?: string[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validatePackageSource(entry: unknown): ValidPackageSource {
|
|
163
|
+
if (typeof entry === "string") {
|
|
164
|
+
if (entry.trim() === "") throw new Error("package source must not be empty")
|
|
165
|
+
return entry
|
|
166
|
+
}
|
|
167
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
168
|
+
throw new Error("package entry must be a string or object")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const value = entry as Record<string, unknown>
|
|
172
|
+
if (Object.keys(value).some((key) => !PACKAGE_SOURCE_KEYS.has(key))) {
|
|
173
|
+
throw new Error("package entry contains unknown keys")
|
|
174
|
+
}
|
|
175
|
+
if (typeof value.source !== "string" || value.source.trim() === "") {
|
|
176
|
+
throw new Error("package object source must be a non-empty string")
|
|
177
|
+
}
|
|
178
|
+
if (value.autoload !== undefined && typeof value.autoload !== "boolean") {
|
|
179
|
+
throw new Error("package object autoload must be a boolean")
|
|
180
|
+
}
|
|
181
|
+
for (const key of PACKAGE_RESOURCE_KEYS) {
|
|
182
|
+
const filter = value[key]
|
|
183
|
+
if (
|
|
184
|
+
filter !== undefined &&
|
|
185
|
+
(!Array.isArray(filter) ||
|
|
186
|
+
!filter.every((item) => typeof item === "string"))
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(`package object ${key} must be an array of strings`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return entry as ValidPackageSource
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isLocalPackageSource(source: string): boolean {
|
|
195
|
+
const trimmed = source.trim()
|
|
196
|
+
return !["npm:", "git:", "github:", "http:", "https:", "ssh:"].some(
|
|
197
|
+
(prefix) => trimmed.startsWith(prefix),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveLocalPath(
|
|
202
|
+
source: string,
|
|
203
|
+
sourceDir: string,
|
|
204
|
+
destDir: string,
|
|
205
|
+
): { path: string; resolved: string; isVimMode: boolean } {
|
|
206
|
+
const sourceIsVimMode = hasVimModeMarker(source)
|
|
207
|
+
let expanded = source
|
|
208
|
+
if (source.startsWith("file://")) expanded = fileURLToPath(source)
|
|
209
|
+
else if (source === "~") expanded = os.homedir()
|
|
210
|
+
else if (source.startsWith("~/"))
|
|
211
|
+
expanded = path.join(os.homedir(), source.slice(2))
|
|
212
|
+
|
|
213
|
+
const wasRelative = !path.isAbsolute(expanded)
|
|
214
|
+
const resolved = wasRelative
|
|
215
|
+
? path.resolve(sourceDir, expanded)
|
|
216
|
+
: path.resolve(expanded)
|
|
217
|
+
let canonical = resolved
|
|
218
|
+
try {
|
|
219
|
+
canonical = fs.realpathSync(resolved)
|
|
220
|
+
} catch {
|
|
221
|
+
// Missing local sources retain their resolved path and Pi reports them later.
|
|
222
|
+
}
|
|
223
|
+
const normalized = wasRelative
|
|
224
|
+
? path.relative(destDir, canonical) || "."
|
|
225
|
+
: canonical
|
|
226
|
+
return {
|
|
227
|
+
path: normalized,
|
|
228
|
+
resolved,
|
|
229
|
+
isVimMode: sourceIsVimMode || hasVimModeMarker(canonical),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeLocalPath(
|
|
234
|
+
source: string,
|
|
235
|
+
sourceDir: string,
|
|
236
|
+
destDir: string,
|
|
237
|
+
): string | null {
|
|
238
|
+
const normalized = resolveLocalPath(source, sourceDir, destDir)
|
|
239
|
+
return normalized.isVimMode ? null : normalized.path
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizePackageSources(
|
|
243
|
+
packages: ValidPackageSource[],
|
|
244
|
+
sourceDir: string,
|
|
245
|
+
destDir: string,
|
|
246
|
+
): ValidPackageSource[] {
|
|
247
|
+
return packages.flatMap((entry) => {
|
|
248
|
+
const source = typeof entry === "string" ? entry : entry.source
|
|
249
|
+
if (!isLocalPackageSource(source)) {
|
|
250
|
+
return hasVimModeMarker(source) ? [] : [entry]
|
|
251
|
+
}
|
|
252
|
+
const normalized = normalizeLocalPath(source, sourceDir, destDir)
|
|
253
|
+
if (normalized === null) return []
|
|
254
|
+
return [
|
|
255
|
+
typeof entry === "string" ? normalized : { ...entry, source: normalized },
|
|
256
|
+
]
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeExtensionSources(
|
|
261
|
+
extensions: string[],
|
|
262
|
+
sourceDir: string,
|
|
263
|
+
destDir: string,
|
|
264
|
+
): string[] {
|
|
265
|
+
const sourceExtensions = path.resolve(sourceDir, "extensions")
|
|
266
|
+
return extensions.flatMap((extension) => {
|
|
267
|
+
const first = extension[0]
|
|
268
|
+
const operator =
|
|
269
|
+
first === "!" || first === "+" || first === "-" ? first : ""
|
|
270
|
+
const target = operator ? extension.slice(1) : extension
|
|
271
|
+
const normalized = resolveLocalPath(target, sourceDir, destDir)
|
|
272
|
+
if (normalized.isVimMode && operator !== "!" && operator !== "-") return []
|
|
273
|
+
const extensionRelative = path.relative(
|
|
274
|
+
sourceExtensions,
|
|
275
|
+
normalized.resolved,
|
|
276
|
+
)
|
|
277
|
+
const isMirrored =
|
|
278
|
+
extensionRelative === "" ||
|
|
279
|
+
(!extensionRelative.startsWith(`..${path.sep}`) &&
|
|
280
|
+
extensionRelative !== ".." &&
|
|
281
|
+
!path.isAbsolute(extensionRelative))
|
|
282
|
+
const rebased = isMirrored
|
|
283
|
+
? path.join("extensions", extensionRelative)
|
|
284
|
+
: normalized.path
|
|
285
|
+
return [`${operator}${rebased.split(path.sep).join("/")}`]
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
49
289
|
export function defaultSourceAgentDir(): string {
|
|
50
290
|
return (
|
|
51
291
|
process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent")
|
|
@@ -53,8 +293,7 @@ export function defaultSourceAgentDir(): string {
|
|
|
53
293
|
}
|
|
54
294
|
|
|
55
295
|
export function defaultRoleAgentDir(): string {
|
|
56
|
-
|
|
57
|
-
return path.join(home, ".config", "apnea", "pi-role-agent")
|
|
296
|
+
return path.join(os.homedir(), ".config", "apnea", "pi-role-agent")
|
|
58
297
|
}
|
|
59
298
|
|
|
60
299
|
function symlinkOrCopy(src: string, dest: string): void {
|
|
@@ -86,8 +325,9 @@ function symlinkOrCopy(src: string, dest: string): void {
|
|
|
86
325
|
|
|
87
326
|
/**
|
|
88
327
|
* Build (or refresh) a PI_CODING_AGENT_DIR for Apnea role panes.
|
|
89
|
-
* - settings.json: user's packages minus pi-vimmode; piVimMode
|
|
90
|
-
* -
|
|
328
|
+
* - settings.json: user's packages/extensions minus pi-vimmode; piVimMode stripped
|
|
329
|
+
* - extensions: safe entries linked individually from the real agent dir
|
|
330
|
+
* - auth/npm/skills/themes/models: linked from the real agent dir
|
|
91
331
|
*
|
|
92
332
|
* Idempotent. Safe to call on every dispatch.
|
|
93
333
|
*/
|
|
@@ -97,36 +337,100 @@ export function materializePiRoleAgentDir(opts?: {
|
|
|
97
337
|
}): string {
|
|
98
338
|
const source = opts?.sourceAgentDir ?? defaultSourceAgentDir()
|
|
99
339
|
const dest = opts?.destDir ?? defaultRoleAgentDir()
|
|
340
|
+
|
|
341
|
+
if (safeIsSymlink(dest)) {
|
|
342
|
+
throw new Error("destination Pi agent directory must not be a symlink")
|
|
343
|
+
}
|
|
344
|
+
if (safeIsSymlink(source)) {
|
|
345
|
+
const target = path.resolve(path.dirname(source), fs.readlinkSync(source))
|
|
346
|
+
const expected = path.resolve(dest)
|
|
347
|
+
if (
|
|
348
|
+
process.platform === "win32"
|
|
349
|
+
? target.toLowerCase() === expected.toLowerCase()
|
|
350
|
+
: target === expected
|
|
351
|
+
) {
|
|
352
|
+
throw new Error("source and destination Pi agent directories must differ")
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (path.resolve(source) === path.resolve(dest)) {
|
|
356
|
+
throw new Error("source and destination Pi agent directories must differ")
|
|
357
|
+
}
|
|
358
|
+
if (fs.existsSync(source) && fs.existsSync(dest)) {
|
|
359
|
+
if (fs.realpathSync(source) === fs.realpathSync(dest)) {
|
|
360
|
+
throw new Error("source and destination Pi agent directories must differ")
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
100
364
|
fs.mkdirSync(dest, { recursive: true })
|
|
365
|
+
if (
|
|
366
|
+
fs.existsSync(source) &&
|
|
367
|
+
fs.realpathSync(source) === fs.realpathSync(dest)
|
|
368
|
+
) {
|
|
369
|
+
throw new Error("source and destination Pi agent directories must differ")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const destSettingsPath = path.join(dest, "settings.json")
|
|
373
|
+
if (safeIsSymlink(destSettingsPath)) {
|
|
374
|
+
throw new Error("destination Pi settings must not be a symlink")
|
|
375
|
+
}
|
|
101
376
|
|
|
102
377
|
const srcSettingsPath = path.join(source, "settings.json")
|
|
103
378
|
let settings: Record<string, unknown> = {}
|
|
104
|
-
if (fs.existsSync(srcSettingsPath)) {
|
|
379
|
+
if (fs.existsSync(srcSettingsPath) || safeIsSymlink(srcSettingsPath)) {
|
|
105
380
|
try {
|
|
106
381
|
const raw = JSON.parse(fs.readFileSync(srcSettingsPath, "utf8"))
|
|
107
|
-
if (raw
|
|
108
|
-
settings
|
|
382
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
383
|
+
throw new Error("settings root must be an object")
|
|
109
384
|
}
|
|
110
|
-
|
|
111
|
-
settings
|
|
385
|
+
settings = { ...(raw as Record<string, unknown>) }
|
|
386
|
+
if ("packages" in settings && !Array.isArray(settings.packages)) {
|
|
387
|
+
throw new Error("packages must be an array")
|
|
388
|
+
}
|
|
389
|
+
if (Array.isArray(settings.packages)) {
|
|
390
|
+
settings.packages = settings.packages.map(validatePackageSource)
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
"extensions" in settings &&
|
|
394
|
+
(!Array.isArray(settings.extensions) ||
|
|
395
|
+
!settings.extensions.every(
|
|
396
|
+
(extension) =>
|
|
397
|
+
typeof extension === "string" && extension.trim() !== "",
|
|
398
|
+
))
|
|
399
|
+
) {
|
|
400
|
+
throw new Error("extensions must be an array of strings")
|
|
401
|
+
}
|
|
402
|
+
} catch (error) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`invalid source Pi settings at ${srcSettingsPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
405
|
+
{ cause: error },
|
|
406
|
+
)
|
|
112
407
|
}
|
|
113
408
|
}
|
|
114
409
|
|
|
115
|
-
|
|
410
|
+
const packages = Array.isArray(settings.packages)
|
|
411
|
+
? (settings.packages as ValidPackageSource[])
|
|
412
|
+
: []
|
|
413
|
+
const extensions = Array.isArray(settings.extensions)
|
|
414
|
+
? (settings.extensions as string[])
|
|
415
|
+
: []
|
|
416
|
+
settings.packages = normalizePackageSources(packages, source, dest)
|
|
417
|
+
settings.extensions = normalizeExtensionSources(extensions, source, dest)
|
|
116
418
|
delete settings.piVimMode
|
|
117
419
|
|
|
118
|
-
|
|
119
|
-
|
|
420
|
+
writeSettingsAtomically(
|
|
421
|
+
dest,
|
|
422
|
+
destSettingsPath,
|
|
120
423
|
`${JSON.stringify(settings, null, 2)}\n`,
|
|
121
|
-
"utf8",
|
|
122
424
|
)
|
|
123
425
|
|
|
426
|
+
const destExtensions = path.join(dest, "extensions")
|
|
427
|
+
materializeExtensionsNoVim(path.join(source, "extensions"), destExtensions)
|
|
428
|
+
|
|
124
429
|
// Reuse identity + installed packages; keep sessions local to role dir.
|
|
125
430
|
for (const name of [
|
|
126
431
|
"auth.json",
|
|
127
432
|
"npm",
|
|
128
433
|
"skills",
|
|
129
|
-
"extensions",
|
|
130
434
|
"themes",
|
|
131
435
|
"models.json",
|
|
132
436
|
"bin",
|
|
@@ -148,6 +452,71 @@ export function materializePiRoleAgentDir(opts?: {
|
|
|
148
452
|
return dest
|
|
149
453
|
}
|
|
150
454
|
|
|
455
|
+
function materializeExtensionsNoVim(sourceDir: string, destDir: string): void {
|
|
456
|
+
fs.rmSync(destDir, { recursive: true, force: true })
|
|
457
|
+
if (!fs.existsSync(sourceDir)) return
|
|
458
|
+
if (extensionPathHasVimModeMarker(sourceDir)) return
|
|
459
|
+
|
|
460
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
461
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
462
|
+
const sourceEntry = path.join(sourceDir, entry.name)
|
|
463
|
+
if (extensionPathHasVimModeMarker(sourceEntry)) continue
|
|
464
|
+
symlinkOrCopy(sourceEntry, path.join(destDir, entry.name))
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function extensionPathHasVimModeMarker(entryPath: string): boolean {
|
|
469
|
+
if (hasVimModeMarker(entryPath)) return true
|
|
470
|
+
try {
|
|
471
|
+
return hasVimModeMarker(fs.realpathSync(entryPath))
|
|
472
|
+
} catch {
|
|
473
|
+
return true
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
type DirectorySyncIo = Pick<typeof fs, "openSync" | "fsyncSync" | "closeSync">
|
|
478
|
+
|
|
479
|
+
export function syncDirectoryAfterRename(
|
|
480
|
+
destDir: string,
|
|
481
|
+
platform: NodeJS.Platform = process.platform,
|
|
482
|
+
io: DirectorySyncIo = fs,
|
|
483
|
+
): void {
|
|
484
|
+
if (platform === "win32") return
|
|
485
|
+
const directory = io.openSync(destDir, fs.constants.O_RDONLY)
|
|
486
|
+
try {
|
|
487
|
+
io.fsyncSync(directory)
|
|
488
|
+
} finally {
|
|
489
|
+
io.closeSync(directory)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function writeSettingsAtomically(
|
|
494
|
+
destDir: string,
|
|
495
|
+
settingsPath: string,
|
|
496
|
+
contents: string,
|
|
497
|
+
): void {
|
|
498
|
+
const temporaryPath = path.join(
|
|
499
|
+
destDir,
|
|
500
|
+
`.settings.json.${process.pid}.${randomUUID()}.tmp`,
|
|
501
|
+
)
|
|
502
|
+
let file: number | undefined
|
|
503
|
+
try {
|
|
504
|
+
// Bun 1.3.7 misinterprets Node's numeric O_CREAT flags on Windows.
|
|
505
|
+
// Exclusive creation also refuses an existing symlink at this random leaf.
|
|
506
|
+
file = fs.openSync(temporaryPath, "wx", 0o600)
|
|
507
|
+
fs.writeFileSync(file, contents, "utf8")
|
|
508
|
+
fs.fsyncSync(file)
|
|
509
|
+
fs.closeSync(file)
|
|
510
|
+
file = undefined
|
|
511
|
+
fs.renameSync(temporaryPath, settingsPath)
|
|
512
|
+
|
|
513
|
+
syncDirectoryAfterRename(destDir)
|
|
514
|
+
} finally {
|
|
515
|
+
if (file !== undefined) fs.closeSync(file)
|
|
516
|
+
fs.rmSync(temporaryPath, { force: true })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
151
520
|
function safeIsSymlink(p: string): boolean {
|
|
152
521
|
try {
|
|
153
522
|
return fs.lstatSync(p).isSymbolicLink()
|
|
@@ -165,7 +534,16 @@ export function wrapInteractiveCmdNoVim(
|
|
|
165
534
|
cmd: string[],
|
|
166
535
|
opts?: { sourceAgentDir?: string; destDir?: string },
|
|
167
536
|
): string[] {
|
|
168
|
-
|
|
537
|
+
const piIndex = piCommandIndex(cmd)
|
|
538
|
+
if (piIndex === null) return cmd
|
|
169
539
|
const agentDir = materializePiRoleAgentDir(opts)
|
|
540
|
+
if (path.basename(cmd[0]!) === "env") {
|
|
541
|
+
return [
|
|
542
|
+
"env",
|
|
543
|
+
...cmd.slice(1, piIndex),
|
|
544
|
+
`PI_CODING_AGENT_DIR=${agentDir}`,
|
|
545
|
+
...cmd.slice(piIndex),
|
|
546
|
+
]
|
|
547
|
+
}
|
|
170
548
|
return ["env", `PI_CODING_AGENT_DIR=${agentDir}`, ...cmd]
|
|
171
549
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naxodev/pi-apnea",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pi adapter for the Apnea multi-role workflow",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nacho Vazquez",
|
|
@@ -60,15 +60,16 @@
|
|
|
60
60
|
"smoke:package": "bun scripts/package-smoke.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@naxodev/apnea": "^0.2.
|
|
63
|
+
"@naxodev/apnea": "^0.2.3"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
69
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
70
70
|
"@types/bun": "^1.3.14",
|
|
71
71
|
"@types/node": "^26.1.2",
|
|
72
|
+
"effect": "4.0.0-rc.111",
|
|
72
73
|
"prettier": "^3.9.0",
|
|
73
74
|
"typescript": "^7.0.2"
|
|
74
75
|
}
|
|
@@ -44,11 +44,11 @@ Never: `apnea reset-rounds` / `/apnea reset-rounds` (human only - it is not a mo
|
|
|
44
44
|
start
|
|
45
45
|
→ dispatch plan → wait
|
|
46
46
|
→ dispatch plan_review → wait
|
|
47
|
-
CHANGES_REQUIRED → dispatch plan
|
|
47
|
+
CHANGES_REQUIRED → dispatch plan → wait → plan_review …
|
|
48
48
|
APPROVED → dispatch phase_package → wait
|
|
49
49
|
→ dispatch code → wait
|
|
50
50
|
→ dispatch code_review → wait
|
|
51
|
-
CHANGES_REQUIRED → dispatch code
|
|
51
|
+
CHANGES_REQUIRED → dispatch code → wait → code_review …
|
|
52
52
|
APPROVED → workflow_commit_phase
|
|
53
53
|
→ more phases? → phase_package …
|
|
54
54
|
→ else → dispatch pr_description → wait → done
|
|
@@ -56,6 +56,8 @@ start
|
|
|
56
56
|
|
|
57
57
|
Follow `@naxodev/apnea/briefs/orchestrator.md` and `@naxodev/apnea/docs/protocol/overview.md`.
|
|
58
58
|
|
|
59
|
+
Do not pass `rework` to authorize a new round. `workflow_wait` records the required target in state, and the matching dispatch consumes it. The 0.2.x flag grants authority only for ambiguous version-1 plan or code migration.
|
|
60
|
+
|
|
59
61
|
## When Pi tools are absent
|
|
60
62
|
|
|
61
63
|
Run the `apnea` CLI instead — same loop, same refusals (see the table above). Any shell that
|
|
@@ -80,6 +82,6 @@ Do **not** stop at the first timeout. Investigate and fix:
|
|
|
80
82
|
2. Prompt stuck in input → `send-keys Enter` or re-`pane run` the pointer.
|
|
81
83
|
3. Idle without artifact → nudge with exact artifact path.
|
|
82
84
|
4. Still working / API retry → `workflow_wait` again.
|
|
83
|
-
5. Pane dead → `dispatch_role` same kind (not rework).
|
|
85
|
+
5. Pane dead → `dispatch_role` same kind with `redeliver=true` (not rework). A live or ambiguous pane must refuse redelivery.
|
|
84
86
|
|
|
85
87
|
Escalate only after two failed recovery attempts, or on round cap / dirty reviewer tree / illegal step / VCS confusion.
|