@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.
@@ -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 = (verb: string, params: Record<string, unknown>) => {
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", { goal: "", action: "resume" })
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(ctx, await run("start", { goal: "", action: "abandon" }))
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) => notify(ctx, await run("status", {})),
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
  },
@@ -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 { toolContent } from "@naxodev/apnea"
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
- for (const op of PI_OPERATIONS) {
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 executePiOperation(
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
- async execute(_id: string, params: Record<string, unknown>) {
74
- return toolContent(await executePiOperation(op.verb, params))
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 out of packages, then wrap
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
- if (!cmd?.length) return false
19
- const bin = path.basename(cmd[0]!)
20
- return bin === "pi"
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
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir()
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 key stripped
90
- * - auth/npm/skills/extensions/themes/models: linked from the real agent dir
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 && typeof raw === "object" && !Array.isArray(raw)) {
108
- settings = { ...(raw as Record<string, unknown>) }
382
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
383
+ throw new Error("settings root must be an object")
109
384
  }
110
- } catch {
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
- settings.packages = filterPackagesNoVim(settings.packages)
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
- fs.writeFileSync(
119
- path.join(dest, "settings.json"),
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
- if (!isPiCmd(cmd)) return cmd
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.0",
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.0",
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 (rework=true) → wait → plan_review …
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 (rework=true) → wait → code_review …
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.