@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.
- package/README.md +8 -10
- package/dist/cli.js +552 -530
- package/docs/adr/0005-harness-profiles.md +1 -1
- package/docs/adr/0010-package-split.md +1 -1
- package/docs/protocol/config.md +7 -22
- package/docs/protocol/manual-gate.md +1 -1
- package/docs/protocol/overview.md +1 -1
- package/extension/domain/herdr.ts +0 -86
- package/extension/domain/paths.ts +1 -2
- package/extension/domain/setup.ts +0 -20
- package/extension/domain/types.ts +0 -9
- package/extension/domain/verify-commands.ts +200 -108
- package/extension/errors.ts +1 -1
- package/extension/schema/config.ts +31 -17
- package/extension/schema/state.ts +16 -3
- package/extension/services/herdr.ts +5 -161
- package/extension/services/vcs.ts +393 -21
- package/extension/workflows/commit.ts +8 -5
- package/extension/workflows/dispatch.ts +60 -177
- package/extension/workflows/setup.ts +2 -109
- package/extension/workflows/start.ts +0 -1
- package/extension/workflows/wait.ts +1 -57
- package/package.json +1 -2
- package/schemas/config.schema.json +6 -6
- package/schemas/state.schema.json +5 -1
- package/herdr-plugin/herdr-plugin.toml +0 -15
- package/herdr-plugin/scripts/run-task.sh +0 -8
|
@@ -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
|
|
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
|
|
218
|
-
|
|
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
|
|
122
|
-
*
|
|
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
|
|
743
|
-
*
|
|
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
|
)
|