@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 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.
@@ -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 | abandon # actions on an existing run",
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 = (verb: string, params: Record<string, unknown>) => {
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", { goal: "", action: "resume" })
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(ctx, await run("start", { goal: "", action: "abandon" }))
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) => notify(ctx, await run("status", {})),
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
  },
@@ -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.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.0"
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.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
+ "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 (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.