@naxodev/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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -21,17 +21,8 @@ export function projectConfigPath(root = cwd()): string {
21
21
  return path.join(apneaRoot(root), "config.json")
22
22
  }
23
23
 
24
- function homedir(): string {
25
- // Env first so an overridden HOME still wins (Bun's os.homedir() reads the
26
- // passwd entry and ignores $HOME), then the passwd entry as the floor.
27
- // Never "": an empty home makes globalConfigPath() cwd-relative, i.e. the
28
- // *project* repo would be read as the trusted global config — the one place
29
- // profiles/cmd_interactive are honoured. node:os is a pure read here.
30
- return process.env.HOME || process.env.USERPROFILE || os.homedir()
31
- }
32
-
33
- export function globalConfigPath(): string {
34
- return path.join(homedir(), ".config", "apnea", "config.json")
24
+ export function globalConfigPath(home = os.homedir()): string {
25
+ return path.join(home, ".config", "apnea", "config.json")
35
26
  }
36
27
 
37
28
  export function artifactsDir(root = cwd()): string {
@@ -16,6 +16,10 @@ const STEP_KEY: Record<DispatchKind, string> = {
16
16
 
17
17
  export const DEFAULT_TIMEOUT_MS = 900_000
18
18
 
19
+ export function deadlineAfter(start: number, duration: number): number {
20
+ return Math.min(Number.MAX_SAFE_INTEGER, start + duration)
21
+ }
22
+
19
23
  export function timeoutMsForKind(
20
24
  kind: DispatchKind,
21
25
  timeouts: Record<string, number>,
@@ -12,10 +12,59 @@ export type Role = "orchestrator" | "planner" | "reviewer" | "coder"
12
12
 
13
13
  export type RoleMode = "oneshot" | "interactive"
14
14
 
15
+ export type PendingDelivery = "manual" | "interactive"
16
+
15
17
  export type Verdict = "APPROVED" | "CHANGES_REQUIRED"
16
18
 
17
19
  export type ReworkTarget = "code" | "phase_package"
18
20
 
21
+ export type RequiredReworkTarget = "plan" | ReworkTarget
22
+
23
+ /** Fields every pending commit carries regardless of backend. */
24
+ export interface PendingCommitCore {
25
+ /** Transaction id; appears in the commit message as `Apnea-Transaction: <id>`. */
26
+ id: string
27
+ /** Phase whose completion this transaction commits. */
28
+ phase_index: number
29
+ /** Full commit message including the `Apnea-Transaction:` trailer line. */
30
+ message: string
31
+ /** Whether completion advances to finishing instead of the next phase. */
32
+ no_remaining_phases: boolean
33
+ /** Repo-relative `.apnea/` path of the verify log for this phase. */
34
+ verify_log: string
35
+ }
36
+
37
+ /** Git anchor captured before any ref moves: the exact commit to create. */
38
+ export interface GitPendingCommit extends PendingCommitCore {
39
+ backend: "git"
40
+ /** Full ref (e.g. `refs/heads/apnea/slug`) the commit must land on. */
41
+ branch: string
42
+ /** Expected HEAD when completion starts; also the created commit's parent. */
43
+ parent_commit: string
44
+ /** Prepared tree id (staged with `.apnea` excluded). */
45
+ tree_id: string
46
+ }
47
+
48
+ /** jj anchor captured right after describing `@`. */
49
+ export interface JjPendingCommit extends PendingCommitCore {
50
+ backend: "jj"
51
+ /** Change id of the described working-copy change. */
52
+ change_id: string
53
+ /** Fingerprint of the change's non-.apnea diff at preparation time. */
54
+ content_fingerprint: string
55
+ }
56
+
57
+ /**
58
+ * Durable record of an in-flight commit. Written after VCS preparation and
59
+ * before completion, so a crash anywhere afterwards can recognize and finish
60
+ * the transaction exactly once.
61
+ */
62
+ export type PendingCommit = GitPendingCommit | JjPendingCommit
63
+
64
+ /** Internal decode marker for ambiguous version-1 planning state. */
65
+ export const LEGACY_PLAN_REWORK = Symbol("apnea.legacy-plan-rework")
66
+ export const LEGACY_CODE_REWORK = Symbol("apnea.legacy-code-rework")
67
+
19
68
  export type VcsBackend = "jj" | "git"
20
69
 
21
70
  export interface Profile {
@@ -35,7 +84,7 @@ export interface ApneaConfig {
35
84
  }
36
85
 
37
86
  export interface RunState {
38
- version: 1
87
+ version: 2
39
88
  slug: string
40
89
  step: Step
41
90
  phase_index: number
@@ -50,6 +99,8 @@ export interface RunState {
50
99
  pending_artifact: string | null
51
100
  /** Role for pending dispatch */
52
101
  pending_role: Role | null
102
+ /** Delivery boundary crossed for the pending dispatch, if known. */
103
+ pending_delivery: PendingDelivery | null
53
104
  /** Herdr pane id for the in-flight dispatch */
54
105
  pending_pane_id: string | null
55
106
  /** Label of that pane (apnea:role:unique) */
@@ -99,8 +150,19 @@ export interface RunState {
99
150
  current_phase_package: string | null
100
151
  /** Last code-review path for commit gate */
101
152
  current_code_review: string | null
102
- /** The next phase-package dispatch must consume planner-owned rework. */
103
- phase_package_rework: boolean
153
+ /** The exact dispatch that owns the next review round, if any. */
154
+ required_rework: RequiredReworkTarget | null
155
+ /**
156
+ * In-flight commit transaction, if any. Set after VCS preparation and
157
+ * cleared only after completion + bookmark succeed; a crash leaves it
158
+ * durable so the next `workflow_commit_phase` call resumes that
159
+ * transaction instead of re-running gates and verification.
160
+ */
161
+ pending_commit: PendingCommit | null
162
+ /** Never serialized. An old planning state needs an explicit assertion. */
163
+ [LEGACY_PLAN_REWORK]?: true
164
+ /** Never serialized. Ambiguous old coding state needs an explicit assertion. */
165
+ [LEGACY_CODE_REWORK]?: true
104
166
  }
105
167
 
106
168
  export interface FrontMatter {
@@ -2,13 +2,13 @@ import { Schema } from "effect"
2
2
  import { err, type ToolErr } from "./result.ts"
3
3
 
4
4
  /** No `.apnea/state.json` for the current project root. */
5
- export class NoRunState extends Schema.TaggedErrorClass<NoRunState>()(
5
+ export class NoRunState extends Schema.TaggedError<NoRunState>()(
6
6
  "NoRunState",
7
7
  {},
8
8
  ) {}
9
9
 
10
10
  /** Tool call refused by the step → legal-tools table. */
11
- export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
11
+ export class IllegalTool extends Schema.TaggedError<IllegalTool>()(
12
12
  "IllegalTool",
13
13
  {
14
14
  step: Schema.String,
@@ -18,7 +18,7 @@ export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
18
18
  ) {}
19
19
 
20
20
  /** Dispatch kind refused at the current step. */
21
- export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
21
+ export class IllegalKind extends Schema.TaggedError<IllegalKind>()(
22
22
  "IllegalKind",
23
23
  {
24
24
  step: Schema.String,
@@ -28,7 +28,7 @@ export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
28
28
  ) {}
29
29
 
30
30
  /** Global or project config missing, invalid, or untrusted. */
31
- export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
31
+ export class ConfigError extends Schema.TaggedError<ConfigError>()(
32
32
  "ConfigError",
33
33
  {
34
34
  message: Schema.String,
@@ -38,7 +38,7 @@ export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
38
38
  ) {}
39
39
 
40
40
  /** `state.json` present but not decodable / inconsistent. */
41
- export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
41
+ export class StateCorrupt extends Schema.TaggedError<StateCorrupt>()(
42
42
  "StateCorrupt",
43
43
  {
44
44
  path: Schema.String,
@@ -47,23 +47,32 @@ export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
47
47
  ) {}
48
48
 
49
49
  /** VCS detect / dirty / commit / bookmark failure. */
50
- export class VcsError extends Schema.TaggedErrorClass<VcsError>()("VcsError", {
50
+ export class VcsError extends Schema.TaggedError<VcsError>()("VcsError", {
51
51
  message: Schema.String,
52
52
  command: Schema.optional(Schema.String),
53
53
  }) {}
54
54
 
55
- /** Herdr CLI or pane failure. */
56
- export class HerdrError extends Schema.TaggedErrorClass<HerdrError>()(
57
- "HerdrError",
55
+ /** Another live Apnea process owns the repository mutation lock. */
56
+ export class OperationLocked extends Schema.TaggedError<OperationLocked>()(
57
+ "OperationLocked",
58
58
  {
59
59
  message: Schema.String,
60
- command: Schema.optional(Schema.String),
61
- details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
60
+ repository: Schema.String,
61
+ lock_path: Schema.String,
62
+ reason: Schema.String,
63
+ pid: Schema.Number,
62
64
  },
63
65
  ) {}
64
66
 
67
+ /** Herdr CLI or pane failure. */
68
+ export class HerdrError extends Schema.TaggedError<HerdrError>()("HerdrError", {
69
+ message: Schema.String,
70
+ command: Schema.optional(Schema.String),
71
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
72
+ }) {}
73
+
65
74
  /** Commit/review gate refused (e.g. verdict not APPROVED). */
66
- export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
75
+ export class GateRefused extends Schema.TaggedError<GateRefused>()(
67
76
  "GateRefused",
68
77
  {
69
78
  gate: Schema.String,
@@ -73,7 +82,7 @@ export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
73
82
  ) {}
74
83
 
75
84
  /** `workflow_wait` hit its timeout without a complete artifact. */
76
- export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
85
+ export class WaitTimeout extends Schema.TaggedError<WaitTimeout>()(
77
86
  "WaitTimeout",
78
87
  {
79
88
  artifact: Schema.String,
@@ -83,7 +92,7 @@ export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
83
92
  ) {}
84
93
 
85
94
  /** `workflow_wait` aborted (Esc / cancel signal). */
86
- export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
95
+ export class WaitAborted extends Schema.TaggedError<WaitAborted>()(
87
96
  "WaitAborted",
88
97
  {
89
98
  artifact: Schema.String,
@@ -91,8 +100,17 @@ export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
91
100
  },
92
101
  ) {}
93
102
 
103
+ /** A non-wait operation was interrupted by its host cancellation signal. */
104
+ export class OperationAborted extends Schema.TaggedError<OperationAborted>()(
105
+ "OperationAborted",
106
+ {
107
+ operation: Schema.String,
108
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
109
+ },
110
+ ) {}
111
+
94
112
  /** Artifact exists but front-matter / shape is invalid. */
95
- export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
113
+ export class ArtifactInvalid extends Schema.TaggedError<ArtifactInvalid>()(
96
114
  "ArtifactInvalid",
97
115
  {
98
116
  artifact: Schema.String,
@@ -101,7 +119,7 @@ export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
101
119
  ) {}
102
120
 
103
121
  /** Phase-package verify commands failed — commit refused. */
104
- export class VerifyFailed extends Schema.TaggedErrorClass<VerifyFailed>()(
122
+ export class VerifyFailed extends Schema.TaggedError<VerifyFailed>()(
105
123
  "VerifyFailed",
106
124
  {
107
125
  commands: Schema.Array(Schema.String),
@@ -119,10 +137,12 @@ export type AppError =
119
137
  | ConfigError
120
138
  | StateCorrupt
121
139
  | VcsError
140
+ | OperationLocked
122
141
  | HerdrError
123
142
  | GateRefused
124
143
  | WaitTimeout
125
144
  | WaitAborted
145
+ | OperationAborted
126
146
  | ArtifactInvalid
127
147
  | VerifyFailed
128
148
 
@@ -133,10 +153,12 @@ const APP_ERROR_TAG_LIST = [
133
153
  "ConfigError",
134
154
  "StateCorrupt",
135
155
  "VcsError",
156
+ "OperationLocked",
136
157
  "HerdrError",
137
158
  "GateRefused",
138
159
  "WaitTimeout",
139
160
  "WaitAborted",
161
+ "OperationAborted",
140
162
  "ArtifactInvalid",
141
163
  "VerifyFailed",
142
164
  ] as const satisfies readonly AppError["_tag"][]
@@ -204,6 +226,15 @@ export function toToolResult(e: AppError): ToolErr {
204
226
  return err(e.message, {
205
227
  data: e.command !== undefined ? { command: e.command } : undefined,
206
228
  })
229
+ case "OperationLocked":
230
+ return err(e.message, {
231
+ data: {
232
+ repository: e.repository,
233
+ lock_path: e.lock_path,
234
+ reason: e.reason,
235
+ pid: e.pid,
236
+ },
237
+ })
207
238
  case "HerdrError":
208
239
  return err(e.message, {
209
240
  data:
@@ -233,6 +264,10 @@ export function toToolResult(e: AppError): ToolErr {
233
264
  return err("workflow_wait aborted (Esc / cancel)", {
234
265
  data: { artifact: e.artifact, ...(e.details ?? {}) },
235
266
  })
267
+ case "OperationAborted":
268
+ return err(`${e.operation} aborted (signal / cancel)`, {
269
+ data: { operation: e.operation, ...(e.details ?? {}) },
270
+ })
236
271
  case "ArtifactInvalid":
237
272
  return err(e.message, { data: { artifact: e.artifact } })
238
273
  case "VerifyFailed":
@@ -0,0 +1,6 @@
1
+ export type OperationHooks = {
2
+ readonly signal?: AbortSignal
3
+ readonly onUpdate?: (partial: {
4
+ content: Array<{ type: "text"; text: string }>
5
+ }) => void
6
+ }
@@ -18,8 +18,8 @@ import {
18
18
  MAX_AUTO_POLL_MS,
19
19
  MIN_POLL_MS,
20
20
  type WaitParams,
21
- type WaitHooks,
22
21
  } from "./workflows/wait.ts"
22
+ import type { OperationHooks } from "./operation-hooks.ts"
23
23
 
24
24
  export type Operation = {
25
25
  /** Pi tool name, or null when the operation is not model-facing. */
@@ -46,14 +46,14 @@ export type Operation = {
46
46
  type RegisteredOperation = Operation & {
47
47
  readonly run: (
48
48
  params: Record<string, unknown>,
49
- hooks?: WaitHooks,
49
+ hooks?: OperationHooks,
50
50
  ) => Promise<ToolResult>
51
51
  }
52
52
 
53
53
  export type ExecuteOperation = (
54
54
  verb: string,
55
55
  params: Record<string, unknown>,
56
- hooks?: WaitHooks,
56
+ hooks?: OperationHooks,
57
57
  ) => Promise<ToolResult>
58
58
 
59
59
  // Sourced from domain/state-machine.ts (not hardcoded here) so a new kind
@@ -86,8 +86,8 @@ function createRegisteredOperations(
86
86
  force: Type.Optional(Type.Boolean()),
87
87
  agents_md: Type.Optional(Type.Boolean()),
88
88
  }),
89
- run: (p) =>
90
- apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter),
89
+ run: (p, hooks) =>
90
+ apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter, hooks),
91
91
  },
92
92
  {
93
93
  tool: "workflow_start",
@@ -115,7 +115,7 @@ function createRegisteredOperations(
115
115
  // Mirrors the guard in index.ts's execute(): without it, action=start
116
116
  // with no goal reaches slugify(undefined) in workflows/start.ts and
117
117
  // throws instead of returning a clean refusal.
118
- run: (p) => {
118
+ run: (p, hooks) => {
119
119
  const params = p as Parameters<typeof workflowStart>[0]
120
120
  const action = params.action ?? "start"
121
121
  if (action === "start" && !params.goal?.trim()) {
@@ -132,16 +132,17 @@ function createRegisteredOperations(
132
132
  action,
133
133
  },
134
134
  hostAdapter,
135
+ hooks,
135
136
  )
136
137
  },
137
138
  },
138
139
  {
139
140
  tool: "dispatch_role",
140
141
  verb: "dispatch",
141
- usage: "<kind> [--rework]",
142
+ usage: "<kind> [--rework] [--redeliver]",
142
143
  summary: "Write the task file and launch a role in a Herdr pane.",
143
144
  guidance:
144
- "One outstanding dispatch at a time. Pass rework=true for plan/code after CHANGES_REQUIRED. Phase-package rework advances its round automatically from review frontmatter.",
145
+ "One outstanding dispatch at a time. Persisted review state selects rework and advances its round. rework=true is a deprecated assertion through 0.2.x and grants authority only for ambiguous version-1 plan or code migration. Use redeliver=true only to reuse matching pending ownership after proving the prior delivery is dead. A complete pending artifact refuses redelivery; call workflow_wait to ingest it.",
145
146
  params: operationParams({
146
147
  kind: DispatchKind,
147
148
  task_markdown: Type.Optional(
@@ -149,14 +150,22 @@ function createRegisteredOperations(
149
150
  ),
150
151
  rework: Type.Optional(
151
152
  Type.Boolean({
152
- description: "Increment round after CHANGES_REQUIRED",
153
+ description:
154
+ "Deprecated assertion through 0.2.x; persisted state owns rework",
155
+ }),
156
+ ),
157
+ redeliver: Type.Optional(
158
+ Type.Boolean({
159
+ description:
160
+ "Reuse matching pending ownership after the prior delivery is demonstrably dead",
153
161
  }),
154
162
  ),
155
163
  }),
156
- run: (p) =>
164
+ run: (p, hooks) =>
157
165
  workflowDispatch(
158
166
  p as Parameters<typeof workflowDispatch>[0],
159
167
  hostAdapter,
168
+ hooks,
160
169
  ),
161
170
  },
162
171
  {
@@ -180,8 +189,9 @@ function createRegisteredOperations(
180
189
  // moves, and then the schema promises a budget the runtime refuses.
181
190
  params: operationParams({
182
191
  poll_ms: Type.Optional(
183
- Type.Number({
192
+ Type.Integer({
184
193
  minimum: MIN_POLL_MS,
194
+ maximum: Number.MAX_SAFE_INTEGER,
185
195
  description:
186
196
  `Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` +
187
197
  `Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` +
@@ -189,7 +199,9 @@ function createRegisteredOperations(
189
199
  }),
190
200
  ),
191
201
  budget_ms: Type.Optional(
192
- Type.Number({
202
+ Type.Integer({
203
+ minimum: 1,
204
+ maximum: Number.MAX_SAFE_INTEGER,
193
205
  description:
194
206
  `How long THIS call may block — not the role's deadline, which comes from config. ` +
195
207
  `Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` +
@@ -223,10 +235,11 @@ function createRegisteredOperations(
223
235
  }),
224
236
  ),
225
237
  }),
226
- run: (p) =>
238
+ run: (p, hooks) =>
227
239
  workflowCommitPhase(
228
240
  p as Parameters<typeof workflowCommitPhase>[0],
229
241
  hostAdapter,
242
+ hooks,
230
243
  ),
231
244
  },
232
245
  {
@@ -236,7 +249,7 @@ function createRegisteredOperations(
236
249
  summary: "Read-only snapshot of run state and legal next calls.",
237
250
  guidance: "Never mutates. Safe to call at any point.",
238
251
  params: operationParams({}),
239
- run: () => workflowStatus(hostAdapter),
252
+ run: (_p, hooks) => workflowStatus(hostAdapter, hooks),
240
253
  },
241
254
  {
242
255
  tool: null,
@@ -254,10 +267,11 @@ function createRegisteredOperations(
254
267
  description: "Round key, e.g. plan_review or phase-01/code_review",
255
268
  }),
256
269
  }),
257
- run: (p) =>
270
+ run: (p, hooks) =>
258
271
  workflowResetRounds(
259
272
  p as Parameters<typeof workflowResetRounds>[0],
260
273
  hostAdapter,
274
+ hooks,
261
275
  ),
262
276
  },
263
277
  ]
@@ -1,5 +1,5 @@
1
1
  import { Cause, Effect, Exit, Layer, Option, Result } from "effect"
2
- import { isAppError, toToolResult } from "./errors.ts"
2
+ import { isAppError, OperationAborted, toToolResult } from "./errors.ts"
3
3
  import type { ToolResult } from "./result.ts"
4
4
 
5
5
  /**
@@ -12,15 +12,32 @@ export async function runToolResult<E, R>(
12
12
  // without the layer would type-check and then die as a service-not-found
13
13
  // defect on every invocation. Pass `Layer.empty` explicitly when R is never.
14
14
  layer: Layer.Layer<R, never, never>,
15
+ hooks: {
16
+ signal?: AbortSignal
17
+ operation?: string
18
+ abortDetails?: Record<string, unknown>
19
+ } = {},
15
20
  ): Promise<ToolResult> {
16
21
  const provided = Effect.provide(effect, layer) as Effect.Effect<ToolResult, E>
17
- const exit = await Effect.runPromiseExit(provided)
22
+ const exit = await Effect.runPromiseExit(provided, { signal: hooks.signal })
18
23
 
19
24
  if (Exit.isSuccess(exit)) {
20
25
  return exit.value
21
26
  }
22
27
 
23
28
  const error = Exit.findErrorOption(exit)
29
+ if (
30
+ hooks.signal?.aborted ||
31
+ Result.isSuccess(Cause.findInterrupt(exit.cause))
32
+ ) {
33
+ return toToolResult(
34
+ new OperationAborted({
35
+ operation: hooks.operation ?? "operation",
36
+ details: hooks.abortDetails,
37
+ }),
38
+ )
39
+ }
40
+
24
41
  if (Option.isSome(error) && isAppError(error.value)) {
25
42
  return toToolResult(error.value)
26
43
  }
@@ -20,18 +20,60 @@ const RoleBindingSchema = Schema.Struct({
20
20
  profile: Schema.String.check(Schema.isMinLength(1)),
21
21
  })
22
22
 
23
+ const ReviewRoundCapSchema = Schema.Int.check(
24
+ Schema.isBetween({ minimum: 1, maximum: 20 }),
25
+ )
26
+ const TimeoutMsSchema = Schema.Int.check(
27
+ Schema.isBetween({ minimum: 1_000, maximum: Number.MAX_SAFE_INTEGER }),
28
+ )
29
+
30
+ function validReviewRoundCap(value: number): boolean {
31
+ return Number.isSafeInteger(value) && value >= 1 && value <= 20
32
+ }
33
+
34
+ function validTimeout(value: number): boolean {
35
+ return Number.isSafeInteger(value) && value >= 1_000
36
+ }
37
+
38
+ function sanitizeConfigNumbers(
39
+ config: Record<string, unknown>,
40
+ ): Record<string, unknown> {
41
+ const sanitized = { ...config }
42
+ if (
43
+ typeof sanitized.review_round_cap === "number" &&
44
+ !validReviewRoundCap(sanitized.review_round_cap)
45
+ ) {
46
+ delete sanitized.review_round_cap
47
+ }
48
+ if (
49
+ sanitized.timeouts_ms &&
50
+ typeof sanitized.timeouts_ms === "object" &&
51
+ !Array.isArray(sanitized.timeouts_ms)
52
+ ) {
53
+ const timeouts = {
54
+ ...(sanitized.timeouts_ms as Record<string, unknown>),
55
+ }
56
+ for (const [key, value] of Object.entries(timeouts)) {
57
+ if (typeof value === "number" && !validTimeout(value)) {
58
+ delete timeouts[key]
59
+ }
60
+ }
61
+ sanitized.timeouts_ms = timeouts
62
+ }
63
+ return sanitized
64
+ }
65
+
23
66
  /**
24
67
  * Mirrors `schemas/config.schema.json` top-level keys.
25
68
  *
26
- * `review_round_cap` / `timeouts_ms` carry no range `check`: an out-of-range
27
- * number must fall back to the default (see below), not fail the decode. A
28
- * hard failure here bricks every tool until the file is hand-edited.
69
+ * Numeric fields are strict here. The decoders remove only invalid numeric
70
+ * values before decoding so published 0.2 per-field fallback remains intact.
29
71
  */
30
72
  export const GlobalConfigSchema = Schema.Struct({
31
73
  profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
32
74
  roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
33
- review_round_cap: Schema.optional(Schema.Number),
34
- timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
75
+ review_round_cap: Schema.optional(ReviewRoundCapSchema),
76
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
35
77
  })
36
78
 
37
79
  const PROJECT_KNOWN = new Set([
@@ -56,8 +98,8 @@ const PROJECT_FORBIDDEN = new Set([
56
98
  */
57
99
  export const ProjectConfigSchema = Schema.Struct({
58
100
  roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
59
- review_round_cap: Schema.optional(Schema.Number),
60
- timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
101
+ review_round_cap: Schema.optional(ReviewRoundCapSchema),
102
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
61
103
  isolation: Schema.optional(Schema.Literal("shared_cwd")),
62
104
  })
63
105
 
@@ -125,7 +167,9 @@ export function decodeGlobalConfig(
125
167
  }
126
168
 
127
169
  const { pane_style: _legacy, ...globalConfig } = obj
128
- const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(globalConfig)
170
+ const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(
171
+ sanitizeConfigNumbers(globalConfig),
172
+ )
129
173
  if (Result.isFailure(decoded)) {
130
174
  return configFail(decoded.failure.message)
131
175
  }
@@ -152,17 +196,14 @@ export function decodeGlobalConfig(
152
196
  const timeouts = { ...DEFAULT_TIMEOUTS }
153
197
  if (d.timeouts_ms) {
154
198
  for (const [k, v] of Object.entries(d.timeouts_ms)) {
155
- if (typeof v === "number" && v >= 1000) timeouts[k] = v
199
+ if (validTimeout(v)) timeouts[k] = v
156
200
  }
157
201
  }
158
202
 
159
203
  return Result.succeed({
160
204
  profiles,
161
205
  roles,
162
- review_round_cap:
163
- typeof d.review_round_cap === "number" && d.review_round_cap >= 1
164
- ? d.review_round_cap
165
- : 3,
206
+ review_round_cap: d.review_round_cap ?? 3,
166
207
  timeouts_ms: timeouts,
167
208
  })
168
209
  }
@@ -228,7 +269,7 @@ export function decodeProjectConfig(
228
269
 
229
270
  const { pane_style: _legacy, ...projectConfig } = obj
230
271
  const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(
231
- projectConfig,
272
+ sanitizeConfigNumbers(projectConfig),
232
273
  {
233
274
  onExcessProperty: "error",
234
275
  },
@@ -256,14 +297,15 @@ export function applyProjectConfig(
256
297
  const timeouts = { ...cfg.timeouts_ms }
257
298
  if (overlay.timeouts_ms) {
258
299
  for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
259
- if (typeof v === "number" && v >= 1000) timeouts[k] = v
300
+ if (validTimeout(v)) timeouts[k] = v
260
301
  }
261
302
  }
262
303
  return {
263
304
  profiles: cfg.profiles,
264
305
  roles,
265
306
  review_round_cap:
266
- overlay.review_round_cap !== undefined && overlay.review_round_cap >= 1
307
+ overlay.review_round_cap !== undefined &&
308
+ validReviewRoundCap(overlay.review_round_cap)
267
309
  ? overlay.review_round_cap
268
310
  : cfg.review_round_cap,
269
311
  timeouts_ms: timeouts,