@naxodev/apnea 0.1.0 → 0.2.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.
@@ -4,7 +4,6 @@ import {
4
4
  DEFAULT_TIMEOUTS,
5
5
  ROLE_MODE,
6
6
  type ApneaConfig,
7
- type PaneStyle,
8
7
  type Profile,
9
8
  type Role,
10
9
  type RoleMode,
@@ -21,8 +20,6 @@ const RoleBindingSchema = Schema.Struct({
21
20
  profile: Schema.String.check(Schema.isMinLength(1)),
22
21
  })
23
22
 
24
- export const PaneStyleSchema = Schema.Literals(["regular", "floating"] as const)
25
-
26
23
  /**
27
24
  * Mirrors `schemas/config.schema.json` top-level keys.
28
25
  *
@@ -35,7 +32,6 @@ export const GlobalConfigSchema = Schema.Struct({
35
32
  roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
36
33
  review_round_cap: Schema.optional(Schema.Number),
37
34
  timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
38
- pane_style: Schema.optional(PaneStyleSchema),
39
35
  })
40
36
 
41
37
  const PROJECT_KNOWN = new Set([
@@ -63,7 +59,6 @@ export const ProjectConfigSchema = Schema.Struct({
63
59
  review_round_cap: Schema.optional(Schema.Number),
64
60
  timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
65
61
  isolation: Schema.optional(Schema.Literal("shared_cwd")),
66
- pane_style: Schema.optional(PaneStyleSchema),
67
62
  })
68
63
 
69
64
  function configFail(
@@ -96,6 +91,16 @@ export function decodeGlobalConfig(
96
91
 
97
92
  const obj = objR.success
98
93
 
94
+ if (
95
+ "pane_style" in obj &&
96
+ obj.pane_style !== "regular" &&
97
+ obj.pane_style !== "floating"
98
+ ) {
99
+ return configFail(
100
+ `invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
101
+ )
102
+ }
103
+
99
104
  if (
100
105
  "isolation" in obj &&
101
106
  obj.isolation !== undefined &&
@@ -119,7 +124,8 @@ export function decodeGlobalConfig(
119
124
  }
120
125
  }
121
126
 
122
- const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(obj)
127
+ const { pane_style: _legacy, ...globalConfig } = obj
128
+ const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(globalConfig)
123
129
  if (Result.isFailure(decoded)) {
124
130
  return configFail(decoded.failure.message)
125
131
  }
@@ -150,11 +156,6 @@ export function decodeGlobalConfig(
150
156
  }
151
157
  }
152
158
 
153
- const pane_style: PaneStyle =
154
- d.pane_style === "regular" || d.pane_style === "floating"
155
- ? d.pane_style
156
- : "regular"
157
-
158
159
  return Result.succeed({
159
160
  profiles,
160
161
  roles,
@@ -163,7 +164,6 @@ export function decodeGlobalConfig(
163
164
  ? d.review_round_cap
164
165
  : 3,
165
166
  timeouts_ms: timeouts,
166
- pane_style,
167
167
  })
168
168
  }
169
169
 
@@ -179,6 +179,18 @@ export function decodeProjectConfig(
179
179
 
180
180
  const obj = objR.success
181
181
 
182
+ // Legacy project configs may contain this retired preference. Validate it
183
+ // before stripping it so typos still fail instead of becoming silent no-ops.
184
+ if (
185
+ "pane_style" in obj &&
186
+ obj.pane_style !== "regular" &&
187
+ obj.pane_style !== "floating"
188
+ ) {
189
+ return configFail(
190
+ `invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
191
+ )
192
+ }
193
+
182
194
  for (const key of Object.keys(obj)) {
183
195
  if (PROJECT_FORBIDDEN.has(key)) {
184
196
  return configFail(
@@ -214,9 +226,13 @@ export function decodeProjectConfig(
214
226
  }
215
227
  }
216
228
 
217
- const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(obj, {
218
- onExcessProperty: "error",
219
- })
229
+ const { pane_style: _legacy, ...projectConfig } = obj
230
+ const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(
231
+ projectConfig,
232
+ {
233
+ onExcessProperty: "error",
234
+ },
235
+ )
220
236
  if (Result.isFailure(decoded)) {
221
237
  return configFail(decoded.failure.message)
222
238
  }
@@ -251,8 +267,6 @@ export function applyProjectConfig(
251
267
  ? overlay.review_round_cap
252
268
  : cfg.review_round_cap,
253
269
  timeouts_ms: timeouts,
254
- pane_style:
255
- overlay.pane_style !== undefined ? overlay.pane_style : cfg.pane_style,
256
270
  }
257
271
  }
258
272
 
@@ -48,8 +48,6 @@ export const RunStateSchema = Schema.Struct({
48
48
  // optional on Encoded so legacy fixtures without pane fields still decode
49
49
  pending_pane_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
50
50
  pending_pane_label: Schema.optionalKey(Schema.NullOr(Schema.String)),
51
- /** Mirrored in `schemas/state.schema.json`; drift is caught by schema.test.ts. */
52
- pending_floating_exit: Schema.optionalKey(Schema.NullOr(Schema.String)),
53
51
  pending_started_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
54
52
  pending_deadline_ms: Schema.optionalKey(Schema.NullOr(Schema.Number)),
55
53
  pending_nudged_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
@@ -69,6 +67,22 @@ export function decodeRunState(
69
67
  json: unknown,
70
68
  path = "state.json",
71
69
  ): Result.Result<RunState, StateCorrupt> {
70
+ if (
71
+ json !== null &&
72
+ typeof json === "object" &&
73
+ !Array.isArray(json) &&
74
+ "pending_floating_exit" in json &&
75
+ json.pending_floating_exit !== null
76
+ ) {
77
+ return Result.fail(
78
+ new StateCorrupt({
79
+ path,
80
+ message:
81
+ 'this run has an active legacy floating dispatch, but floating dispatch was removed; dismiss or terminate the old popup first, then run `apnea abandon` and `apnea start "<goal>"`',
82
+ }),
83
+ )
84
+ }
85
+
72
86
  const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
73
87
  if (Result.isFailure(decoded)) {
74
88
  return Result.fail(
@@ -95,7 +109,6 @@ export function decodeRunState(
95
109
  pending_role: d.pending_role,
96
110
  pending_pane_id: d.pending_pane_id ?? null,
97
111
  pending_pane_label: d.pending_pane_label ?? null,
98
- pending_floating_exit: d.pending_floating_exit ?? null,
99
112
  pending_started_at: d.pending_started_at ?? null,
100
113
  pending_deadline_ms: d.pending_deadline_ms ?? null,
101
114
  pending_nudged_at: d.pending_nudged_at ?? null,
@@ -1,13 +1,8 @@
1
1
  import { spawnSync } from "node:child_process"
2
2
  import * as fs from "node:fs"
3
- import * as os from "node:os"
4
3
  import * as path from "node:path"
5
4
  import { Clock, Context, Effect, Layer, Option, Result } from "effect"
6
- import {
7
- floatingTaskScriptBody,
8
- parseHerdrVersion,
9
- shellJoin,
10
- } from "../domain/herdr.ts"
5
+ import { shellJoin } from "../domain/herdr.ts"
11
6
  import { HerdrError } from "../errors.ts"
12
7
  import type { ApneaHostAdapter } from "../host-adapter.ts"
13
8
  import { neutralHostAdapter } from "../host-adapter.ts"
@@ -33,8 +28,6 @@ export interface HerdrService {
33
28
  readonly enabled: Effect.Effect<boolean>
34
29
  /** Dispatch preflight that distinguishes a stale pane from CLI failures. */
35
30
  readonly availability: Effect.Effect<HerdrAvailability, HerdrError>
36
- readonly version: Effect.Effect<[number, number, number] | null>
37
- readonly hasApneaPlugin: Effect.Effect<boolean>
38
31
  readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo>
39
32
  readonly paneRun: (
40
33
  paneId: string,
@@ -50,20 +43,6 @@ export interface HerdrService {
50
43
  prompt: string,
51
44
  prefer: RolePaneRef | null,
52
45
  ) => Effect.Effect<InteractiveLaunch, HerdrError>
53
- readonly writeFloatingTaskScript: (
54
- scriptAbs: string,
55
- root: string,
56
- cmd: string[],
57
- prompt: string,
58
- exitFileAbs: string,
59
- ) => Effect.Effect<void, HerdrError>
60
- readonly openFloatingPane: (
61
- taskScriptAbs: string,
62
- root: string,
63
- ) => Effect.Effect<void, HerdrError>
64
- readonly linkPlugin: (
65
- dir: string,
66
- ) => Effect.Effect<{ ok: boolean; raw: string }>
67
46
  }
68
47
 
69
48
  export class Herdr extends Context.Service<Herdr, HerdrService>()(
@@ -118,11 +97,8 @@ function isExecutableFile(abs: string): boolean {
118
97
  }
119
98
 
120
99
  /**
121
- * Resolve a oneshot binary against the orchestrator environment.
122
- * Floating plugin popups get a stripped PATH (no ~/.local/bin etc.), so bare
123
- * names like `claude` exit 127 unless we bake an absolute path into the script.
124
- * Walks PATH directly — no `which` subprocess (which itself vanishes when PATH
125
- * is overridden for tests or minimal envs).
100
+ * Resolve a binary against the current environment. Walks PATH directly so
101
+ * setup detection does not depend on a separate `which` executable.
126
102
  */
127
103
  export function resolveExecutable(
128
104
  bin: string,
@@ -141,36 +117,6 @@ export function resolveExecutable(
141
117
  return null
142
118
  }
143
119
 
144
- /**
145
- * PATH for floating plugin panes: orchestrator PATH plus common user-local
146
- * bin dirs so child tools the oneshot agent spawns still resolve.
147
- */
148
- export function floatingPanePath(
149
- base: string = process.env.PATH ?? "",
150
- home: string = os.homedir(),
151
- ): string {
152
- const extras = [
153
- path.join(home, ".local", "bin"),
154
- path.join(home, ".bun", "bin"),
155
- "/opt/homebrew/bin",
156
- "/usr/local/bin",
157
- ]
158
- const parts = base.split(path.delimiter).filter(Boolean)
159
- const seen = new Set(parts)
160
- for (const extra of extras) {
161
- if (seen.has(extra)) continue
162
- try {
163
- if (fs.statSync(extra).isDirectory()) {
164
- parts.push(extra)
165
- seen.add(extra)
166
- }
167
- } catch {
168
- // skip missing dirs
169
- }
170
- }
171
- return parts.join(path.delimiter)
172
- }
173
-
174
120
  function herdrEnabledSync(): boolean {
175
121
  return process.env.HERDR_ENV === "1"
176
122
  }
@@ -309,24 +255,6 @@ function paneSendKeysSync(paneId: string, keys: string[]): void {
309
255
  }
310
256
  }
311
257
 
312
- function herdrVersionSync(): [number, number, number] | null {
313
- return parseHerdrVersion(herdrCli(["--version"]).raw)
314
- }
315
-
316
- function hasApneaPluginSync(): boolean {
317
- const r = herdrCli(["plugin", "list", "--plugin", "apnea", "--json"])
318
- const json = r.json
319
- if (json) {
320
- const res = resultOf(json)
321
- const plugins = (res?.plugins as Array<Record<string, unknown>>) ?? []
322
- if (plugins.some((p) => p.plugin_id === "apnea" || p.id === "apnea")) {
323
- return true
324
- }
325
- }
326
- // Fallback when JSON shape is unexpected but the id still appears in output.
327
- return /"(?:plugin_id|id)"\s*:\s*"apnea"/.test(r.raw)
328
- }
329
-
330
258
  function paneForegroundNamesSync(paneId: string): string[] {
331
259
  try {
332
260
  const r = spawnSync("herdr", ["pane", "process-info", "--pane", paneId], {
@@ -739,9 +667,8 @@ function runInteractivePromptImpl(
739
667
  }
740
668
 
741
669
  /**
742
- * Thin Herdr service: pane lifecycle, interactive-prompt dispatch, and
743
- * floating-oneshot popups. Depends on nothing (spawns + node:fs directly,
744
- * like `VcsLive`'s `run`).
670
+ * Thin Herdr service for pane lifecycle and interactive-prompt dispatch.
671
+ * Depends on nothing, like `VcsLive`'s `run`.
745
672
  */
746
673
  export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
747
674
  Layer.effect(
@@ -755,10 +682,6 @@ export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
755
682
  catch: toHerdrError,
756
683
  }),
757
684
 
758
- version: Effect.sync(herdrVersionSync),
759
-
760
- hasApneaPlugin: Effect.sync(hasApneaPluginSync),
761
-
762
685
  paneGet: (paneId) => Effect.sync(() => paneGetSync(paneId)),
763
686
 
764
687
  paneRun,
@@ -774,85 +697,6 @@ export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
774
697
 
775
698
  runInteractivePrompt: (...args) =>
776
699
  runInteractivePromptImpl(hostAdapter, ...args),
777
-
778
- writeFloatingTaskScript: (scriptAbs, root, cmd, prompt, exitFileAbs) =>
779
- Effect.try({
780
- try: () => {
781
- if (cmd.length === 0) {
782
- throw new HerdrError({
783
- message:
784
- "floating oneshot cmd is empty; set cmd_oneshot on the role profile",
785
- })
786
- }
787
- const bin = cmd[0]
788
- if (bin === undefined || bin === "") {
789
- throw new HerdrError({
790
- message:
791
- "floating oneshot binary is empty; set cmd_oneshot on the role profile",
792
- })
793
- }
794
- const resolved = resolveExecutable(bin)
795
- if (!resolved) {
796
- throw new HerdrError({
797
- message: `floating oneshot binary "${bin}" not found on PATH; use an absolute cmd_oneshot or set pane_style=regular`,
798
- })
799
- }
800
- const resolvedCmd = [resolved, ...cmd.slice(1)]
801
- // No `exec`: EXIT trap must run after the oneshot exits (Hangup
802
- // included). End-of-options `--` before the prompt so variadic
803
- // flags like Claude's `--allowedTools <tools...>` cannot swallow
804
- // the prompt as another tool.
805
- const body = floatingTaskScriptBody({
806
- root,
807
- resolvedCmd,
808
- prompt,
809
- exitFileAbs,
810
- })
811
- fs.writeFileSync(scriptAbs, body, "utf8")
812
- fs.chmodSync(scriptAbs, 0o755)
813
- },
814
- catch: toHerdrError,
815
- }),
816
-
817
- openFloatingPane: (taskScriptAbs, _root) =>
818
- Effect.try({
819
- try: () => {
820
- const r = herdrCli([
821
- "plugin",
822
- "pane",
823
- "open",
824
- "--plugin",
825
- "apnea",
826
- "--entrypoint",
827
- "worker",
828
- "--placement",
829
- "popup",
830
- "--env",
831
- `APNEA_TASK_SCRIPT=${taskScriptAbs}`,
832
- "--env",
833
- `PATH=${floatingPanePath()}`,
834
- ])
835
- if (!r.ok) {
836
- const raw = r.raw.trim()
837
- if (/popup already open/i.test(raw)) {
838
- throw new HerdrError({
839
- message:
840
- "floating popup already open — herdr allows only one; dismiss it or workflow_wait for the in-flight oneshot before dispatching again",
841
- })
842
- }
843
- throw new HerdrError({
844
- message: `herdr plugin pane open failed: ${raw || r.raw}`,
845
- })
846
- }
847
- },
848
- catch: toHerdrError,
849
- }),
850
-
851
- linkPlugin: (dir) =>
852
- Effect.sync(() => {
853
- const r = herdrCli(["plugin", "link", dir])
854
- return { ok: r.ok, raw: r.raw }
855
- }),
856
700
  }),
857
701
  ),
858
702
  )