@naxodev/pi-apnea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nacho Vazquez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @naxodev/pi-apnea
2
+
3
+ Pi adapter for the [Apnea workflow engine](../apnea/README.md). It registers Apnea tools, slash commands, skills, and prompts while `@naxodev/apnea` provides the shared workflow and standalone CLI.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 22.19 or later
8
+ - Pi 0.83.x or 0.84.x
9
+ - Bun 1.3.7 or later for the transitive `apnea` executable
10
+ - Herdr and at least one supported agent CLI, as documented by [`@naxodev/apnea`](../apnea/README.md#requirements)
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pi install npm:@naxodev/pi-apnea
16
+ ```
17
+
18
+ The normal `@naxodev/apnea` dependency installs transitively, including the `apnea` executable. Pi loads this package's `extension`, `skills`, and `prompts` resources.
19
+
20
+ Pi role panes use a dedicated `PI_CODING_AGENT_DIR` without `pi-vimmode`. The user's orchestrator session remains unchanged.
21
+
22
+ ## Quickstart
23
+
24
+ Run these commands inside Pi:
25
+
26
+ ```text
27
+ /apnea setup
28
+ /apnea start describe the implementation goal
29
+ /apnea status
30
+ ```
31
+
32
+ `setup` creates global profiles in `~/.config/apnea/config.json` and links the Herdr plugin when Herdr is available. `start` begins one workflow against the current working copy. `status` reports the current step and next legal operation.
33
+
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
+
36
+ ## Verify
37
+
38
+ 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.
39
+
40
+ Apnea executes repository-controlled text through configured agent CLIs and can execute planner-authored verification commands. Read the [trust model](../apnea/SECURITY.md) before using it with an untrusted repository.
41
+
42
+ ## Versioning
43
+
44
+ Compatible core patch releases flow through the `^0.1.0` dependency range. Incompatible host interface changes require coordinated minor releases of both packages.
45
+
46
+ ## Contributing
47
+
48
+ Use the workspace [contribution guide](../../CONTRIBUTING.md) for setup, checks, and release policy. Use [GitHub Discussions](https://github.com/naxodev/ai/discussions) for usage questions and the workspace [security policy](../../SECURITY.md) for private vulnerability reports.
49
+
50
+ ## License
51
+
52
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,333 @@
1
+ /**
2
+ * User-facing slash commands with `/` autocomplete.
3
+ * These call the same functions as the LLM tools — no workflow_* typing required.
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
6
+ import {
7
+ DISPATCH_KINDS,
8
+ formatResult,
9
+ parseFlags,
10
+ parseNumFlag,
11
+ type DispatchKind,
12
+ type ExecuteOperation,
13
+ type Operation,
14
+ type ToolResult,
15
+ } from "@naxodev/apnea"
16
+ import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
17
+
18
+ export const SUBS = [
19
+ ...PI_OPERATIONS.map((o) => o.verb),
20
+ "resume",
21
+ "abandon",
22
+ "help",
23
+ ] as const satisfies readonly string[]
24
+
25
+ function notify(
26
+ ctx: {
27
+ ui: { notify: (m: string, l?: "info" | "error" | "warning") => void }
28
+ },
29
+ r: ToolResult,
30
+ ) {
31
+ const text = formatResult(r)
32
+ ctx.ui.notify(text, r.ok ? "info" : "error")
33
+ }
34
+
35
+ function orchestratorKickMessage(
36
+ kind: "start" | "resume",
37
+ goal?: string,
38
+ ): string {
39
+ if (kind === "start") {
40
+ return [
41
+ "You are the Apnea orchestrator for this run. Schedule only — no product code.",
42
+ goal ? `Goal: ${goal}` : null,
43
+ "State is step=planning with no pending dispatch.",
44
+ 'Immediately call dispatch_role with kind="plan", then workflow_wait.',
45
+ "Then continue the loop: plan_review → phase_package → code → code_review → workflow_commit_phase → … → pr_description.",
46
+ "Use workflow_status if unsure. Never call workflow_reset_rounds. Escalate timeouts/caps/dirty-reviewer to the human.",
47
+ ]
48
+ .filter(Boolean)
49
+ .join("\n")
50
+ }
51
+ return [
52
+ "You are the Apnea orchestrator resuming this run. Schedule only — no product code.",
53
+ "Call workflow_status (and workflow_start action=resume if needed).",
54
+ "If a pending artifact exists, workflow_wait. Else dispatch the legal kind for the current step.",
55
+ "Never auto-invent steps; never workflow_reset_rounds; never product code.",
56
+ ].join("\n")
57
+ }
58
+
59
+ function helpText(operations: readonly Operation[]): string {
60
+ // verb + usage on one line (what to type), summary indented below (what
61
+ // it does) — usage is what regressed when helpText() first went generated:
62
+ // it rendered only the LLM-facing summary and dropped the flag syntax.
63
+ const lines = operations.map((o) => {
64
+ const invocation = o.usage ? `${o.verb} ${o.usage}` : o.verb
65
+ return ` /apnea ${invocation}\n ${o.summary}`
66
+ })
67
+ return [
68
+ "Apnea commands (tools remain for the model; you use /apnea):",
69
+ ...lines,
70
+ " /apnea resume | abandon # actions on an existing run",
71
+ ` dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`,
72
+ " /apnea help",
73
+ ].join("\n")
74
+ }
75
+
76
+ export function registerApneaCommands(
77
+ pi: ExtensionAPI,
78
+ operations: readonly Operation[] = PI_OPERATIONS,
79
+ execute: ExecuteOperation = executePiOperation,
80
+ ): void {
81
+ const run = (verb: string, params: Record<string, unknown>) => {
82
+ const operation = operations.find((candidate) => candidate.verb === verb)
83
+ if (!operation) throw new Error(`Missing Apnea operation: ${verb}`)
84
+ return execute(operation.verb, params)
85
+ }
86
+ const kick = (kind: "start" | "resume", goal?: string) => {
87
+ pi.sendUserMessage(orchestratorKickMessage(kind, goal))
88
+ }
89
+
90
+ pi.registerCommand("apnea", {
91
+ description: "Apnea workflow: start, status, dispatch, wait, commit, …",
92
+ getArgumentCompletions: (prefix: string) => {
93
+ const parts = prefix.trimStart().split(/\s+/)
94
+ // completing first token (subcommand)
95
+ if (parts.length <= 1) {
96
+ const p = parts[0] ?? ""
97
+ const hits = SUBS.filter((s) => s.startsWith(p)).map((s) => ({
98
+ value: s,
99
+ label: s,
100
+ }))
101
+ return hits.length ? hits : null
102
+ }
103
+ const sub = parts[0]
104
+ if (sub === "dispatch") {
105
+ const p = parts[1] ?? ""
106
+ // Complete the kind token only — Pi replaces the last token.
107
+ const hits = DISPATCH_KINDS.filter((k) => k.startsWith(p)).map((k) => ({
108
+ value: k,
109
+ label: k,
110
+ }))
111
+ return hits.length ? hits : null
112
+ }
113
+ if (sub === "setup") {
114
+ const p = parts[1] ?? ""
115
+ const flags = ["--project", "--force", "--agents-md"]
116
+ const hits = flags
117
+ .filter((f) => f.startsWith(p) || p === "")
118
+ .map((f) => ({ value: f, label: f }))
119
+ return hits.length ? hits : null
120
+ }
121
+ if (sub === "start" && parts.length === 2 && parts[1]?.startsWith("--")) {
122
+ const flags = ["--allow-dirty", "--slug="]
123
+ const p = parts[1] ?? ""
124
+ const hits = flags
125
+ .filter((f) => f.startsWith(p))
126
+ .map((f) => ({ value: f, label: f }))
127
+ return hits.length ? hits : null
128
+ }
129
+ if (sub === "commit") {
130
+ const p = parts[1] ?? ""
131
+ if (p.startsWith("-") || p === "") {
132
+ const hits = ["--done"]
133
+ .filter((f) => f.startsWith(p) || p === "")
134
+ .map((f) => ({ value: f, label: f }))
135
+ return hits.length ? hits : null
136
+ }
137
+ }
138
+ if (sub === "reset-rounds") {
139
+ const gates = [
140
+ "plan_review",
141
+ "phase-01/code_review",
142
+ "phase-02/code_review",
143
+ ]
144
+ const p = parts[1] ?? ""
145
+ const hits = gates
146
+ .filter((g) => g.startsWith(p))
147
+ .map((g) => ({ value: g, label: g }))
148
+ return hits.length ? hits : null
149
+ }
150
+ return null
151
+ },
152
+ handler: async (args, ctx) => {
153
+ const raw = args.trim()
154
+ if (!raw || raw === "help") {
155
+ ctx.ui.notify(helpText(operations), "info")
156
+ return
157
+ }
158
+
159
+ const tokens = raw.split(/\s+/).filter(Boolean)
160
+ const sub = tokens[0]!
161
+ const { flags, values, rest } = parseFlags(tokens.slice(1))
162
+
163
+ try {
164
+ switch (sub) {
165
+ case "help":
166
+ ctx.ui.notify(helpText(operations), "info")
167
+ return
168
+
169
+ case "setup":
170
+ notify(
171
+ ctx,
172
+ await run("setup", {
173
+ project: flags.has("project"),
174
+ force: flags.has("force"),
175
+ agents_md: flags.has("agents-md"),
176
+ }),
177
+ )
178
+ return
179
+
180
+ case "start": {
181
+ // goal is everything not a flag; support --slug=x
182
+ const slug = values.get("slug")
183
+ const goal = rest.join(" ").trim()
184
+ if (!goal) {
185
+ ctx.ui.notify(
186
+ "Usage: /apnea start <goal> [--allow-dirty] [--slug=name]",
187
+ "error",
188
+ )
189
+ return
190
+ }
191
+ const r = await run("start", {
192
+ goal,
193
+ slug,
194
+ allow_dirty: flags.has("allow-dirty"),
195
+ action: "start",
196
+ })
197
+ notify(ctx, r)
198
+ if (r.ok) kick("start", goal)
199
+ return
200
+ }
201
+
202
+ case "resume": {
203
+ const r = await run("start", { goal: "", action: "resume" })
204
+ notify(ctx, r)
205
+ if (r.ok) kick("resume")
206
+ return
207
+ }
208
+
209
+ case "abandon":
210
+ notify(ctx, await run("start", { goal: "", action: "abandon" }))
211
+ return
212
+
213
+ case "status":
214
+ notify(ctx, await run("status", {}))
215
+ return
216
+
217
+ case "wait": {
218
+ // `--timeout` and `--budget` are the same knob: how long THIS
219
+ // call blocks. The role's deadline comes from config, stamped
220
+ // at dispatch. Same flags as `apnea wait`; only the default
221
+ // differs, because this surface has no host shell to time out.
222
+ const poll = parseNumFlag(values, "poll")
223
+ if (!poll.ok) {
224
+ ctx.ui.notify(
225
+ `Usage: /apnea wait [--poll=<ms>] (got --poll=${poll.raw})`,
226
+ "error",
227
+ )
228
+ return
229
+ }
230
+ const budget = parseNumFlag(values, "budget")
231
+ if (!budget.ok) {
232
+ ctx.ui.notify(
233
+ `Usage: /apnea wait [--budget=<ms>] (got --budget=${budget.raw})`,
234
+ "error",
235
+ )
236
+ return
237
+ }
238
+ const timeout = parseNumFlag(values, "timeout")
239
+ if (!timeout.ok) {
240
+ ctx.ui.notify(
241
+ `Usage: /apnea wait [--timeout=<ms>] (got --timeout=${timeout.raw})`,
242
+ "error",
243
+ )
244
+ return
245
+ }
246
+ const r = await run("wait", {
247
+ poll_ms: poll.value,
248
+ // Unbounded by default, like the Pi tool in `index.ts`:
249
+ // `/apnea` runs inside Pi, which has no shell timeout, so
250
+ // the CLI's chunking default would end the wait with a
251
+ // green "still waiting" toast and nothing left polling.
252
+ budget_ms:
253
+ budget.value ?? timeout.value ?? Number.MAX_SAFE_INTEGER,
254
+ })
255
+ notify(ctx, r)
256
+ return
257
+ }
258
+
259
+ case "dispatch": {
260
+ const kind = rest[0] as DispatchKind | undefined
261
+ if (!kind || !DISPATCH_KINDS.includes(kind)) {
262
+ ctx.ui.notify(
263
+ `Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
264
+ "error",
265
+ )
266
+ return
267
+ }
268
+ notify(
269
+ ctx,
270
+ await run("dispatch", {
271
+ kind,
272
+ rework: flags.has("rework"),
273
+ }),
274
+ )
275
+ return
276
+ }
277
+
278
+ case "commit": {
279
+ // `parseFlags` already strips `--` tokens into `flags`/`values`;
280
+ // `rest` never contains them, so no filter is needed here.
281
+ const message = rest.join(" ").trim() || undefined
282
+ notify(
283
+ ctx,
284
+ await run("commit", {
285
+ message,
286
+ no_remaining_phases: flags.has("done"),
287
+ }),
288
+ )
289
+ return
290
+ }
291
+
292
+ case "reset-rounds": {
293
+ const gate = rest[0]
294
+ if (!gate) {
295
+ ctx.ui.notify("Usage: /apnea reset-rounds <gate>", "error")
296
+ return
297
+ }
298
+ notify(ctx, await run("reset-rounds", { gate }))
299
+ return
300
+ }
301
+
302
+ default:
303
+ ctx.ui.notify(
304
+ `Unknown subcommand: ${sub}\n${helpText(operations)}`,
305
+ "error",
306
+ )
307
+ }
308
+ } catch (e) {
309
+ ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
310
+ }
311
+ },
312
+ })
313
+
314
+ // Short aliases that also show in `/` autocomplete
315
+ pi.registerCommand("apnea-status", {
316
+ description: "Apnea: read-only run status (alias of /apnea status)",
317
+ handler: async (_args, ctx) => notify(ctx, await run("status", {})),
318
+ })
319
+
320
+ pi.registerCommand("apnea-start", {
321
+ description: "Apnea: start a run — /apnea-start <goal>",
322
+ handler: async (args, ctx) => {
323
+ const goal = args.trim()
324
+ if (!goal) {
325
+ ctx.ui.notify("Usage: /apnea-start <goal>", "error")
326
+ return
327
+ }
328
+ const r = await run("start", { goal, action: "start" })
329
+ notify(ctx, r)
330
+ if (r.ok) kick("start", goal)
331
+ },
332
+ })
333
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @naxodev/pi-apnea — Pi adapter for the Apnea workflow.
3
+ *
4
+ * Definitions come from @naxodev/apnea; this file only binds them to Pi.
5
+ * The standalone CLI binds the same registry to argv.
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
8
+ import { toolContent } from "@naxodev/apnea"
9
+ import { registerApneaCommands } from "./commands.ts"
10
+ import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ // `/apnea …` for humans (autocomplete); tools remain for the model
14
+ registerApneaCommands(pi, PI_OPERATIONS, executePiOperation)
15
+
16
+ for (const op of PI_OPERATIONS) {
17
+ if (op.tool === null) continue
18
+
19
+ // wait is the one operation with streaming + abort; Pi's exclusive.
20
+ if (op.tool === "workflow_wait") {
21
+ pi.registerTool({
22
+ name: op.tool,
23
+ label: "Apnea wait",
24
+ description: [op.summary, op.guidance].filter(Boolean).join(" "),
25
+ parameters: op.params,
26
+ async execute(
27
+ _id: string,
28
+ params: { poll_ms?: number; budget_ms?: number },
29
+ signal: AbortSignal | undefined,
30
+ onUpdate:
31
+ | ((partial: {
32
+ content: Array<{ type: "text"; text: string }>
33
+ details: unknown
34
+ }) => void)
35
+ | undefined,
36
+ ) {
37
+ return toolContent(
38
+ // Pi blocks in one chunk by design: it streams progress and
39
+ // can be interrupted, so it has no host shell timeout to fit
40
+ // inside. The registry handler no longer injects this — only
41
+ // the CLI reaches that, and it must stay bounded.
42
+ await executePiOperation(
43
+ op.verb,
44
+ {
45
+ ...params,
46
+ budget_ms: params.budget_ms ?? Number.MAX_SAFE_INTEGER,
47
+ },
48
+ {
49
+ signal,
50
+ onUpdate: onUpdate
51
+ ? (partial) =>
52
+ onUpdate({
53
+ content: partial.content,
54
+ details: {
55
+ ok: true,
56
+ message: partial.content[0]?.text ?? "",
57
+ },
58
+ })
59
+ : undefined,
60
+ },
61
+ ),
62
+ )
63
+ },
64
+ })
65
+ continue
66
+ }
67
+
68
+ pi.registerTool({
69
+ name: op.tool,
70
+ label: `Apnea ${op.verb}`,
71
+ description: [op.summary, op.guidance].filter(Boolean).join(" "),
72
+ parameters: op.params,
73
+ async execute(_id: string, params: Record<string, unknown>) {
74
+ return toolContent(await executePiOperation(op.verb, params))
75
+ },
76
+ })
77
+ }
78
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Role-pane pi launches must not load pi-vimmode. Modal vim intercepts
3
+ * herdr `pane run` pastes and leaves the pointer sitting in INSERT — the
4
+ * single biggest cause of idle-without-artifact stalls for the coder.
5
+ *
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
9
+ * best-effort `/vimmode off` slash command.
10
+ */
11
+ import * as fs from "node:fs"
12
+ import * as os from "node:os"
13
+ import * as path from "node:path"
14
+
15
+ const PI_VIMMODE_MARKERS = ["pi-vimmode", "pekochan069/pi-vimmode"]
16
+
17
+ 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"
21
+ }
22
+
23
+ export function packageSource(entry: unknown): string | null {
24
+ if (typeof entry === "string") return entry
25
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
26
+ const src = (entry as Record<string, unknown>).source
27
+ return typeof src === "string" ? src : null
28
+ }
29
+ return null
30
+ }
31
+
32
+ /** True when a packages[] entry is (or wraps) pi-vimmode. */
33
+ export function isPiVimModePackage(entry: unknown): boolean {
34
+ const src = packageSource(entry)
35
+ if (!src) return false
36
+ const lower = src.toLowerCase()
37
+ return PI_VIMMODE_MARKERS.some((m) => lower.includes(m))
38
+ }
39
+
40
+ /**
41
+ * Drop pi-vimmode from a packages list. Leaves every other entry intact
42
+ * (string form and object form with filters).
43
+ */
44
+ export function filterPackagesNoVim(packages: unknown): unknown[] {
45
+ if (!Array.isArray(packages)) return []
46
+ return packages.filter((p) => !isPiVimModePackage(p))
47
+ }
48
+
49
+ export function defaultSourceAgentDir(): string {
50
+ return (
51
+ process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent")
52
+ )
53
+ }
54
+
55
+ 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")
58
+ }
59
+
60
+ function symlinkOrCopy(src: string, dest: string): void {
61
+ if (!fs.existsSync(src)) return
62
+ if (path.resolve(src) === path.resolve(dest)) return
63
+ try {
64
+ if (fs.existsSync(dest) || fs.lstatSync(dest).isSymbolicLink()) {
65
+ fs.rmSync(dest, { recursive: true, force: true })
66
+ }
67
+ } catch {
68
+ try {
69
+ fs.rmSync(dest, { recursive: true, force: true })
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+ try {
75
+ fs.symlinkSync(src, dest)
76
+ } catch {
77
+ // Windows or no-symlink FS: copy
78
+ const st = fs.statSync(src)
79
+ if (st.isDirectory()) {
80
+ fs.cpSync(src, dest, { recursive: true, force: true })
81
+ } else {
82
+ fs.copyFileSync(src, dest)
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 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
91
+ *
92
+ * Idempotent. Safe to call on every dispatch.
93
+ */
94
+ export function materializePiRoleAgentDir(opts?: {
95
+ sourceAgentDir?: string
96
+ destDir?: string
97
+ }): string {
98
+ const source = opts?.sourceAgentDir ?? defaultSourceAgentDir()
99
+ const dest = opts?.destDir ?? defaultRoleAgentDir()
100
+ fs.mkdirSync(dest, { recursive: true })
101
+
102
+ const srcSettingsPath = path.join(source, "settings.json")
103
+ let settings: Record<string, unknown> = {}
104
+ if (fs.existsSync(srcSettingsPath)) {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(srcSettingsPath, "utf8"))
107
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
108
+ settings = { ...(raw as Record<string, unknown>) }
109
+ }
110
+ } catch {
111
+ settings = {}
112
+ }
113
+ }
114
+
115
+ settings.packages = filterPackagesNoVim(settings.packages)
116
+ delete settings.piVimMode
117
+
118
+ fs.writeFileSync(
119
+ path.join(dest, "settings.json"),
120
+ `${JSON.stringify(settings, null, 2)}\n`,
121
+ "utf8",
122
+ )
123
+
124
+ // Reuse identity + installed packages; keep sessions local to role dir.
125
+ for (const name of [
126
+ "auth.json",
127
+ "npm",
128
+ "skills",
129
+ "extensions",
130
+ "themes",
131
+ "models.json",
132
+ "bin",
133
+ "tools",
134
+ ]) {
135
+ symlinkOrCopy(path.join(source, name), path.join(dest, name))
136
+ }
137
+
138
+ // Optional: pi-vimmode.config.js must not apply either
139
+ const vimCfg = path.join(dest, "pi-vimmode.config.js")
140
+ if (fs.existsSync(vimCfg) || safeIsSymlink(vimCfg)) {
141
+ try {
142
+ fs.rmSync(vimCfg, { force: true })
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ }
147
+
148
+ return dest
149
+ }
150
+
151
+ function safeIsSymlink(p: string): boolean {
152
+ try {
153
+ return fs.lstatSync(p).isSymbolicLink()
154
+ } catch {
155
+ return false
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Prefix a pi interactive command with env PI_CODING_AGENT_DIR=... so the
161
+ * role pane never loads pi-vimmode. Non-pi cmds pass through unchanged.
162
+ * `opts` is for tests; production callers omit it.
163
+ */
164
+ export function wrapInteractiveCmdNoVim(
165
+ cmd: string[],
166
+ opts?: { sourceAgentDir?: string; destDir?: string },
167
+ ): string[] {
168
+ if (!isPiCmd(cmd)) return cmd
169
+ const agentDir = materializePiRoleAgentDir(opts)
170
+ return ["env", `PI_CODING_AGENT_DIR=${agentDir}`, ...cmd]
171
+ }
@@ -0,0 +1,20 @@
1
+ import {
2
+ createExecutor,
3
+ createOperations,
4
+ type ApneaHostAdapter,
5
+ } from "@naxodev/apnea"
6
+ import {
7
+ isPiCmd,
8
+ materializePiRoleAgentDir,
9
+ wrapInteractiveCmdNoVim,
10
+ } from "./pi-role-agent.ts"
11
+
12
+ export const piHostAdapter: ApneaHostAdapter = {
13
+ materializeRoleAgentDir: materializePiRoleAgentDir,
14
+ prepareInteractiveCommand: wrapInteractiveCmdNoVim,
15
+ beforeInteractivePrompt: (command) =>
16
+ isPiCmd(command) ? "/vimmode off" : null,
17
+ }
18
+
19
+ export const PI_OPERATIONS = createOperations(piHostAdapter)
20
+ export const executePiOperation = createExecutor(piHostAdapter)
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@naxodev/pi-apnea",
3
+ "version": "0.1.0",
4
+ "description": "Pi adapter for the Apnea multi-role workflow",
5
+ "license": "MIT",
6
+ "author": "Nacho Vazquez",
7
+ "funding": "https://github.com/sponsors/NachoVazquez",
8
+ "homepage": "https://github.com/naxodev/ai/tree/main/packages/pi-apnea#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/naxodev/ai.git",
12
+ "directory": "packages/pi-apnea"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/naxodev/ai/issues"
16
+ },
17
+ "type": "module",
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi",
21
+ "apnea",
22
+ "herdr",
23
+ "workflow"
24
+ ],
25
+ "pi": {
26
+ "skills": [
27
+ "./skills"
28
+ ],
29
+ "prompts": [
30
+ "./prompts"
31
+ ],
32
+ "extensions": [
33
+ "./extension"
34
+ ]
35
+ },
36
+ "files": [
37
+ "LICENSE",
38
+ "README.md",
39
+ "extension",
40
+ "prompts",
41
+ "skills",
42
+ "!extension/**/*.test.ts"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
48
+ "packageManager": "bun@1.3.7",
49
+ "engines": {
50
+ "node": ">=22.19.0"
51
+ },
52
+ "scripts": {
53
+ "check": "bun run typecheck && bun run test && bun run format:check && bun run pack:check && bun run smoke:package",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "bun test extension scripts",
56
+ "format": "prettier --write .",
57
+ "format:check": "prettier --check .",
58
+ "pack:check": "bun scripts/verify-pack.ts",
59
+ "prepublish:core": "bun scripts/check-core-published.ts",
60
+ "smoke:package": "bun scripts/package-smoke.ts"
61
+ },
62
+ "dependencies": {
63
+ "@naxodev/apnea": "^0.1.0"
64
+ },
65
+ "peerDependencies": {
66
+ "@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0"
67
+ },
68
+ "devDependencies": {
69
+ "@earendil-works/pi-coding-agent": "0.84.0",
70
+ "@types/bun": "^1.3.14",
71
+ "@types/node": "^26.1.2",
72
+ "prettier": "^3.9.0",
73
+ "typescript": "^7.0.2"
74
+ }
75
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Initialize Apnea config (prefer /apnea setup)
3
+ ---
4
+
5
+ Prefer the slash command (no model needed):
6
+
7
+ ```
8
+ /apnea setup
9
+ /apnea setup --project
10
+ ```
11
+
12
+ If tools/commands are unavailable, run the apnea-setup skill steps:
13
+
14
+ 1. Detect `pi`, `claude`, `codex`, `herdr`, `jj`, `git` on PATH.
15
+ 2. Create or update `~/.config/apnea/config.json` with **profiles** only (no project cmds).
16
+ 3. Optional: `.apnea/config.json` with role→profile names only.
17
+ 4. Point at `@naxodev/apnea/docs/protocol/config.md`.
18
+ 5. Note `pane_style` (`regular` default / `floating` opt-in, needs herdr ≥ 0.7.4) — setup never writes it.
19
+
20
+ Do not invent binaries. Do not write `cmd` into project config.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: apnea-orchestrator
3
+ description: Drive an Apnea run as hybrid orchestrator (schedule only). Use when starting or resuming a multi-role plan→review→code loop in Herdr.
4
+ ---
5
+
6
+ # apnea-orchestrator
7
+
8
+ ## Goal
9
+
10
+ Drive one **Run** to `pr-description.md` without writing product code.
11
+
12
+ ## Critical: start is not the loop
13
+
14
+ `workflow_start` / `/apnea start` only writes `.apnea/state.json` with `step=planning`.
15
+ **It does not launch any role.** Stopping after start is a failed orchestration.
16
+
17
+ Immediately after a successful start:
18
+
19
+ 1. `dispatch_role` with `kind: "plan"`
20
+ 2. `workflow_wait`
21
+ 3. Continue the loop until `done`
22
+
23
+ ## When tools exist
24
+
25
+ Use only these; the CLI column is the same operation for a harness with no Apnea Pi plugin. See `@naxodev/apnea/docs/adr/0009-cli-driver-split.md`.
26
+
27
+ | Tool | CLI |
28
+ | ----------------------- | ----------------------- |
29
+ | `workflow_start` | `apnea start <goal>` |
30
+ | `dispatch_role` | `apnea dispatch <kind>` |
31
+ | `workflow_wait` | `apnea wait` |
32
+ | `workflow_commit_phase` | `apnea commit` |
33
+ | `workflow_status` | `apnea status` |
34
+
35
+ `apnea wait` returns exit `3` when its own budget runs out but the role still has time. That is not a failure. Call `apnea wait` again, as many times as it takes. The default budget is 90s, so the call returns before a typical 120s shell timeout kills it.
36
+
37
+ Do not pass `--budget` below 72s. A call must be long enough to contain the 60s idle nudge and the four polls that detect a dead harness; a shorter call is refused, with the required floor in the message. Raising `--poll` raises that floor, and if you omit `--budget` it is raised to match, so the call runs longer rather than failing. Keep `--poll` between 250ms and 26999ms, or state `--budget` yourself — above that the floor outgrows a typical shell timeout. Whether the role was already nudged, already took its one-time extension, and its deadline all persist across calls — only these two duration checks need one call to complete.
38
+
39
+ Never: `apnea reset-rounds` / `/apnea reset-rounds` (human only - it is not a model-facing tool; see `@naxodev/apnea/docs/adr/0002-orchestrator-authority.md`), never edit `.apnea/state.json` by hand, never implement product code.
40
+
41
+ ### Loop (do not skip steps)
42
+
43
+ ```text
44
+ start
45
+ → dispatch plan → wait
46
+ → dispatch plan_review → wait
47
+ CHANGES_REQUIRED → dispatch plan (rework=true) → wait → plan_review …
48
+ APPROVED → dispatch phase_package → wait
49
+ → dispatch code → wait
50
+ → dispatch code_review → wait
51
+ CHANGES_REQUIRED → dispatch code (rework=true) → wait → code_review …
52
+ APPROVED → workflow_commit_phase
53
+ → more phases? → phase_package …
54
+ → else → dispatch pr_description → wait → done
55
+ ```
56
+
57
+ Follow `@naxodev/apnea/briefs/orchestrator.md` and `@naxodev/apnea/docs/protocol/overview.md`.
58
+
59
+ ## When Pi tools are absent
60
+
61
+ Run the `apnea` CLI instead — same loop, same refusals (see the table above). Any shell that
62
+ can run `apnea` can hold the orchestrator seat.
63
+
64
+ ## Paper mode (no shell at all)
65
+
66
+ If you cannot run shell commands either, you may still help the **human** orchestrate:
67
+
68
+ - Propose exact task file contents and pointer messages
69
+ - Name exact artifact paths for the current step
70
+ - Parse front-matter when asked
71
+ - Remind verify-before-commit
72
+
73
+ Do not pretend tools exist.
74
+
75
+ ## Active recovery before escalate
76
+
77
+ Do **not** stop at the first timeout. Investigate and fix:
78
+
79
+ 1. `herdr pane get` / `pane read` the pending role pane.
80
+ 2. Prompt stuck in input → `send-keys Enter` or re-`pane run` the pointer.
81
+ 3. Idle without artifact → nudge with exact artifact path.
82
+ 4. Still working / API retry → `workflow_wait` again.
83
+ 5. Pane dead → `dispatch_role` same kind (not rework).
84
+
85
+ Escalate only after two failed recovery attempts, or on round cap / dirty reviewer tree / illegal step / VCS confusion.
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: apnea-setup
3
+ description: Create global Apnea profiles and optional project role bindings. Prefer /apnea setup when the extension is loaded. Use this skill when installing Apnea without commands, switching providers, or fixing config trust errors.
4
+ ---
5
+
6
+ # apnea-setup
7
+
8
+ ## Prefer
9
+
10
+ If the Apnea extension is loaded:
11
+
12
+ ```
13
+ /apnea setup
14
+ /apnea setup --project # also write .apnea/config.json bindings
15
+ /apnea setup --force # replace global profiles instead of merge
16
+ ```
17
+
18
+ That path is deterministic (no LLM). Use this skill only as a fallback.
19
+
20
+ ## Goal
21
+
22
+ Leave the user with a valid **global** profile config and optional **project** role bindings. Never put binaries in project config.
23
+
24
+ ## Steps (fallback)
25
+
26
+ 1. Read `@naxodev/apnea/docs/protocol/config.md` if available.
27
+ 2. Check PATH for: `pi`, `claude`, `codex`, `herdr`, `jj`, `git`.
28
+ 3. Ensure `~/.config/apnea/` exists.
29
+ 4. Write or merge `~/.config/apnea/config.json`:
30
+ - Define profiles only for binaries that exist.
31
+ - Include both `cmd_oneshot` and `cmd_interactive` where the harness supports them.
32
+ - Bind default roles: orchestrator+coder → pi profile; planner+reviewer → claude or codex if present, else pi.
33
+ - Preserve an existing `pane_style` (`regular`|`floating`) if present; **never write/flip `pane_style`** — it is user opt-in only.
34
+ 5. When herdr is on PATH, provision the herdr `apnea` plugin: copy `herdr-plugin/` from the installed `@naxodev/apnea` package to `~/.config/apnea/herdr-plugin/` (refresh on every run) and, if herdr ≥ 0.7.4 and the plugin is not already linked, run `herdr plugin link ~/.config/apnea/herdr-plugin`. On herdr < 0.7.4, skip link and note the upgrade requirement.
35
+ 6. Optionally write `.apnea/config.json` with **only** `{ "roles": { "...": { "profile": "..." } } }`.
36
+ 7. Validate against trust rules: no project `cmd_*`, no unknown isolation modes.
37
+ 8. Next: `/apnea start <goal>` inside Herdr.
38
+
39
+ ## Refuse
40
+
41
+ - Writing `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin` under the project.