@naxodev/pi-apnea 0.2.0 → 0.2.1
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/extension/commands.ts +36 -17
- package/extension/index.ts +43 -5
- package/extension/pi-role-agent.ts +398 -20
- package/package.json +2 -2
- package/skills/apnea-orchestrator/SKILL.md +5 -3
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,
|
|
@@ -78,10 +78,14 @@ export function registerApneaCommands(
|
|
|
78
78
|
operations: readonly Operation[] = PI_OPERATIONS,
|
|
79
79
|
execute: ExecuteOperation = executePiOperation,
|
|
80
80
|
): void {
|
|
81
|
-
const run = (
|
|
81
|
+
const run = (
|
|
82
|
+
signal: AbortSignal | undefined,
|
|
83
|
+
verb: string,
|
|
84
|
+
params: Record<string, unknown>,
|
|
85
|
+
) => {
|
|
82
86
|
const operation = operations.find((candidate) => candidate.verb === verb)
|
|
83
87
|
if (!operation) throw new Error(`Missing Apnea operation: ${verb}`)
|
|
84
|
-
return execute(operation.verb, params)
|
|
88
|
+
return execute(operation.verb, params, { signal })
|
|
85
89
|
}
|
|
86
90
|
const kick = (kind: "start" | "resume", goal?: string) => {
|
|
87
91
|
pi.sendUserMessage(orchestratorKickMessage(kind, goal))
|
|
@@ -158,9 +162,16 @@ export function registerApneaCommands(
|
|
|
158
162
|
|
|
159
163
|
const tokens = raw.split(/\s+/).filter(Boolean)
|
|
160
164
|
const sub = tokens[0]!
|
|
161
|
-
const { flags, values, rest } = parseFlags(tokens.slice(1))
|
|
162
|
-
|
|
163
165
|
try {
|
|
166
|
+
const parsed = parseOperationArgs(sub, tokens.slice(1), {
|
|
167
|
+
surface: "slash",
|
|
168
|
+
})
|
|
169
|
+
if (!parsed.ok) {
|
|
170
|
+
ctx.ui.notify(parsed.message, "error")
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const { flags, values, positional: rest } = parsed
|
|
174
|
+
|
|
164
175
|
switch (sub) {
|
|
165
176
|
case "help":
|
|
166
177
|
ctx.ui.notify(helpText(operations), "info")
|
|
@@ -169,7 +180,7 @@ export function registerApneaCommands(
|
|
|
169
180
|
case "setup":
|
|
170
181
|
notify(
|
|
171
182
|
ctx,
|
|
172
|
-
await run("setup", {
|
|
183
|
+
await run(ctx.signal, "setup", {
|
|
173
184
|
project: flags.has("project"),
|
|
174
185
|
force: flags.has("force"),
|
|
175
186
|
agents_md: flags.has("agents-md"),
|
|
@@ -188,7 +199,7 @@ export function registerApneaCommands(
|
|
|
188
199
|
)
|
|
189
200
|
return
|
|
190
201
|
}
|
|
191
|
-
const r = await run("start", {
|
|
202
|
+
const r = await run(ctx.signal, "start", {
|
|
192
203
|
goal,
|
|
193
204
|
slug,
|
|
194
205
|
allow_dirty: flags.has("allow-dirty"),
|
|
@@ -200,18 +211,24 @@ export function registerApneaCommands(
|
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
case "resume": {
|
|
203
|
-
const r = await run("start", {
|
|
214
|
+
const r = await run(ctx.signal, "start", {
|
|
215
|
+
goal: "",
|
|
216
|
+
action: "resume",
|
|
217
|
+
})
|
|
204
218
|
notify(ctx, r)
|
|
205
219
|
if (r.ok) kick("resume")
|
|
206
220
|
return
|
|
207
221
|
}
|
|
208
222
|
|
|
209
223
|
case "abandon":
|
|
210
|
-
notify(
|
|
224
|
+
notify(
|
|
225
|
+
ctx,
|
|
226
|
+
await run(ctx.signal, "start", { goal: "", action: "abandon" }),
|
|
227
|
+
)
|
|
211
228
|
return
|
|
212
229
|
|
|
213
230
|
case "status":
|
|
214
|
-
notify(ctx, await run("status", {}))
|
|
231
|
+
notify(ctx, await run(ctx.signal, "status", {}))
|
|
215
232
|
return
|
|
216
233
|
|
|
217
234
|
case "wait": {
|
|
@@ -243,7 +260,7 @@ export function registerApneaCommands(
|
|
|
243
260
|
)
|
|
244
261
|
return
|
|
245
262
|
}
|
|
246
|
-
const r = await run("wait", {
|
|
263
|
+
const r = await run(ctx.signal, "wait", {
|
|
247
264
|
poll_ms: poll.value,
|
|
248
265
|
// Unbounded by default, like the Pi tool in `index.ts`:
|
|
249
266
|
// `/apnea` runs inside Pi, which has no shell timeout, so
|
|
@@ -260,16 +277,17 @@ export function registerApneaCommands(
|
|
|
260
277
|
const kind = rest[0] as DispatchKind | undefined
|
|
261
278
|
if (!kind || !DISPATCH_KINDS.includes(kind)) {
|
|
262
279
|
ctx.ui.notify(
|
|
263
|
-
`Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
|
|
280
|
+
`Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework] [--redeliver]`,
|
|
264
281
|
"error",
|
|
265
282
|
)
|
|
266
283
|
return
|
|
267
284
|
}
|
|
268
285
|
notify(
|
|
269
286
|
ctx,
|
|
270
|
-
await run("dispatch", {
|
|
287
|
+
await run(ctx.signal, "dispatch", {
|
|
271
288
|
kind,
|
|
272
289
|
rework: flags.has("rework"),
|
|
290
|
+
redeliver: flags.has("redeliver"),
|
|
273
291
|
}),
|
|
274
292
|
)
|
|
275
293
|
return
|
|
@@ -281,7 +299,7 @@ export function registerApneaCommands(
|
|
|
281
299
|
const message = rest.join(" ").trim() || undefined
|
|
282
300
|
notify(
|
|
283
301
|
ctx,
|
|
284
|
-
await run("commit", {
|
|
302
|
+
await run(ctx.signal, "commit", {
|
|
285
303
|
message,
|
|
286
304
|
no_remaining_phases: flags.has("done"),
|
|
287
305
|
}),
|
|
@@ -295,7 +313,7 @@ export function registerApneaCommands(
|
|
|
295
313
|
ctx.ui.notify("Usage: /apnea reset-rounds <gate>", "error")
|
|
296
314
|
return
|
|
297
315
|
}
|
|
298
|
-
notify(ctx, await run("reset-rounds", { gate }))
|
|
316
|
+
notify(ctx, await run(ctx.signal, "reset-rounds", { gate }))
|
|
299
317
|
return
|
|
300
318
|
}
|
|
301
319
|
|
|
@@ -314,7 +332,8 @@ export function registerApneaCommands(
|
|
|
314
332
|
// Short aliases that also show in `/` autocomplete
|
|
315
333
|
pi.registerCommand("apnea-status", {
|
|
316
334
|
description: "Apnea: read-only run status (alias of /apnea status)",
|
|
317
|
-
handler: async (_args, ctx) =>
|
|
335
|
+
handler: async (_args, ctx) =>
|
|
336
|
+
notify(ctx, await run(ctx.signal, "status", {})),
|
|
318
337
|
})
|
|
319
338
|
|
|
320
339
|
pi.registerCommand("apnea-start", {
|
|
@@ -325,7 +344,7 @@ export function registerApneaCommands(
|
|
|
325
344
|
ctx.ui.notify("Usage: /apnea-start <goal>", "error")
|
|
326
345
|
return
|
|
327
346
|
}
|
|
328
|
-
const r = await run("start", { goal, action: "start" })
|
|
347
|
+
const r = await run(ctx.signal, "start", { goal, action: "start" })
|
|
329
348
|
notify(ctx, r)
|
|
330
349
|
if (r.ok) kick("start", goal)
|
|
331
350
|
},
|
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.1",
|
|
4
4
|
"description": "Pi adapter for the Apnea multi-role workflow",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nacho Vazquez",
|
|
@@ -66,7 +66,7 @@
|
|
|
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
72
|
"prettier": "^3.9.0",
|
|
@@ -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.
|