@twelvehart/orcats 0.2.2 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/acp-client.d.ts +1 -1
  3. package/dist/backends/acp-client.d.ts.map +1 -1
  4. package/dist/backends/acp-run.d.ts.map +1 -1
  5. package/dist/backends/claude-run.d.ts.map +1 -1
  6. package/dist/backends/codex-jsonl.d.ts +2 -1
  7. package/dist/backends/codex-jsonl.d.ts.map +1 -1
  8. package/dist/backends/codex-run.d.ts +2 -1
  9. package/dist/backends/codex-run.d.ts.map +1 -1
  10. package/dist/backends/codex.d.ts.map +1 -1
  11. package/dist/backends/pi-run.d.ts.map +1 -1
  12. package/dist/backends/select.d.ts +1 -1
  13. package/dist/backends/select.d.ts.map +1 -1
  14. package/dist/backends/subprocess-run.d.ts +4 -5
  15. package/dist/backends/subprocess-run.d.ts.map +1 -1
  16. package/dist/backends/subprocess-termination.d.ts +4 -0
  17. package/dist/backends/subprocess-termination.d.ts.map +1 -0
  18. package/dist/baseline/index.d.ts +3 -1
  19. package/dist/baseline/index.d.ts.map +1 -1
  20. package/dist/cli/version.d.ts +1 -1
  21. package/dist/conversation/conversation.d.ts +2 -0
  22. package/dist/conversation/conversation.d.ts.map +1 -1
  23. package/dist/conversation/settlement-reservation.d.ts +10 -0
  24. package/dist/conversation/settlement-reservation.d.ts.map +1 -0
  25. package/dist/model/backend-config.d.ts +2 -0
  26. package/dist/model/backend-config.d.ts.map +1 -1
  27. package/package.json +6 -5
  28. package/src/backends/acp-client.ts +12 -10
  29. package/src/backends/acp-run.ts +18 -18
  30. package/src/backends/claude-run.ts +7 -5
  31. package/src/backends/codex-jsonl.ts +5 -0
  32. package/src/backends/codex-run.ts +15 -1
  33. package/src/backends/codex.ts +4 -1
  34. package/src/backends/pi-run.ts +8 -2
  35. package/src/backends/select.ts +4 -1
  36. package/src/backends/subprocess-run.ts +534 -58
  37. package/src/backends/subprocess-termination.ts +56 -0
  38. package/src/baseline/index.ts +4 -2
  39. package/src/cli/embedded.ts +19 -0
  40. package/src/cli/main.ts +6 -3
  41. package/src/cli/version.ts +1 -1
  42. package/src/conversation/conversation.ts +92 -7
  43. package/src/conversation/settlement-reservation.ts +127 -0
  44. package/src/model/backend-config.ts +8 -0
  45. package/src/monitor/index.ts +4 -2
  46. package/src/run-output/index.ts +2 -2
@@ -0,0 +1,56 @@
1
+ import type { SubprocessProcess } from "./subprocess-run.ts";
2
+
3
+ const DefaultSubprocessTerminationGraceMs = 1_000;
4
+ const DefaultSubprocessForceGraceMs = 1_000;
5
+
6
+ const exitWaitCancellations = new WeakMap<
7
+ SubprocessProcess,
8
+ (error: unknown) => void
9
+ >();
10
+
11
+ export function registerSubprocessExitWaitCancellation(
12
+ process: SubprocessProcess,
13
+ cancel: (error: unknown) => void
14
+ ): void {
15
+ exitWaitCancellations.set(process, cancel);
16
+ }
17
+
18
+ export async function terminateSubprocess(
19
+ process: SubprocessProcess,
20
+ gracefulMs = DefaultSubprocessTerminationGraceMs,
21
+ forceMs = DefaultSubprocessForceGraceMs
22
+ ): Promise<void> {
23
+ try {
24
+ process.kill("SIGTERM");
25
+ if (await exitsWithin(process.exit, gracefulMs)) {
26
+ return;
27
+ }
28
+
29
+ process.kill("SIGKILL");
30
+ if (!(await exitsWithin(process.exit, forceMs))) {
31
+ throw new Error("subprocess did not exit after SIGKILL");
32
+ }
33
+ } catch (error) {
34
+ const cancelExitWait = exitWaitCancellations.get(process);
35
+ exitWaitCancellations.delete(process);
36
+ cancelExitWait?.(error);
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ async function exitsWithin(
42
+ exit: Promise<number | null>,
43
+ timeoutMs: number
44
+ ): Promise<boolean> {
45
+ let timer: ReturnType<typeof setTimeout> | undefined;
46
+ const timedOut = new Promise<false>((resolve) => {
47
+ timer = setTimeout(() => {
48
+ resolve(false);
49
+ }, Math.max(timeoutMs, 0));
50
+ });
51
+ try {
52
+ return await Promise.race([exit.then(() => true as const), timedOut]);
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
@@ -31,6 +31,8 @@ export interface BaselineRepairResult {
31
31
  readonly usage?: Usage | undefined;
32
32
  }
33
33
 
34
+ type BaselineRepairOutput<T = void> = BaselineRepairResult | T;
35
+
34
36
  export interface BaselineGateResult {
35
37
  readonly policy: BaselinePolicy;
36
38
  readonly status: "clean" | "repaired";
@@ -44,7 +46,7 @@ export interface RunBaselineGateOptions {
44
46
  readonly commands: readonly VerificationCommand[];
45
47
  readonly policy?: BaselinePolicy;
46
48
  readonly commandTool?: CommandTool;
47
- readonly repair?: (issues: readonly BaselineGateIssue[]) => Promise<BaselineRepairResult | void>;
49
+ readonly repair?: (issues: readonly BaselineGateIssue[]) => Promise<BaselineRepairOutput>;
48
50
  readonly monitor?: Pick<WorkflowMonitor, "stage" | "recordOutcome" | "recordFailure">;
49
51
  readonly snapshotDir?: string;
50
52
  readonly maxIterations?: number;
@@ -205,7 +207,7 @@ async function runBaselineGateInner(options: RunBaselineGateOptions): Promise<Ba
205
207
 
206
208
  let usage: Usage | undefined;
207
209
  const loop = await fixLoop<BaselineGateIssue>(
208
- async () => ok(latest.passed ? [] : [{ message: renderValidationFailure(latest.logs), fixable: true as const }]),
210
+ () => Promise.resolve(ok(latest.passed ? [] : [{ message: renderValidationFailure(latest.logs), fixable: true as const }])),
209
211
  async (issues) => {
210
212
  const repaired = await options.repair?.(issues);
211
213
  usage = addUsage(usage, repaired?.usage);
@@ -37,6 +37,22 @@ export function canResolveOrca(scriptPath: string): boolean {
37
37
  }
38
38
 
39
39
  function canResolveFrom(specifier: string, fromDir: string): boolean {
40
+ if (isBunExecutable()) {
41
+ const probe = Bun.spawnSync(
42
+ [
43
+ process.execPath,
44
+ "--eval",
45
+ `Bun.resolveSync(${JSON.stringify(specifier)}, ${JSON.stringify(fromDir)});`,
46
+ ],
47
+ {
48
+ stdin: "ignore",
49
+ stdout: "ignore",
50
+ stderr: "ignore",
51
+ },
52
+ );
53
+ return probe.exitCode === 0;
54
+ }
55
+
40
56
  try {
41
57
  Bun.resolveSync(specifier, fromDir);
42
58
  return true;
@@ -46,6 +62,9 @@ function canResolveFrom(specifier: string, fromDir: string): boolean {
46
62
  }
47
63
 
48
64
  function hasProjectPackage(specifier: string, fromDir: string, includeSelfReference: boolean): boolean {
65
+ if (!canResolveFrom(specifier, fromDir)) {
66
+ return false;
67
+ }
49
68
  for (let dir = fromDir; ; dir = dirname(dir)) {
50
69
  if (existsSync(join(dir, "node_modules", ...specifier.split("/"), "package.json"))) {
51
70
  return true;
package/src/cli/main.ts CHANGED
@@ -304,8 +304,10 @@ function createCliReporter(): RunReporter {
304
304
  return createRunReporter({
305
305
  sinks: [
306
306
  createRunPresenter({
307
- isTTY: process.stderr.isTTY === true,
308
- writeDiagnostic: (message) => process.stderr.write(message),
307
+ isTTY: process.stderr.isTTY,
308
+ writeDiagnostic: (message) => {
309
+ process.stderr.write(message);
310
+ },
309
311
  }),
310
312
  ],
311
313
  });
@@ -319,7 +321,8 @@ function describeError(error: unknown): string {
319
321
  return error.reason;
320
322
  }
321
323
  try {
322
- return JSON.stringify(error);
324
+ const serialized = JSON.stringify(error) as string | undefined;
325
+ return serialized ?? String(error);
323
326
  } catch {
324
327
  return String(error);
325
328
  }
@@ -1 +1 @@
1
- export const ORCA_VERSION = "0.2.2";
1
+ export const ORCA_VERSION = "0.3.0";
@@ -4,9 +4,15 @@ import {
4
4
  type BackendTag,
5
5
  type ConversationEvent,
6
6
  type RuntimeError,
7
+ backendFailed,
7
8
  unsupportedFeature
8
9
  } from "../model/index.ts";
9
10
  import { BoundedAsyncQueue } from "./queue.ts";
11
+ import {
12
+ deferConversationSettlement,
13
+ markConversationCancellationComplete,
14
+ registerConversationCancellationFailureHandler
15
+ } from "./settlement-reservation.ts";
10
16
 
11
17
  export type Outcome<B extends BackendTag = BackendTag> =
12
18
  | { readonly type: "success"; readonly result: BackendResult<B> }
@@ -39,6 +45,7 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
39
45
  private readonly outcome: Promise<Outcome<B>>;
40
46
  private settle!: (outcome: Outcome<B>) => void;
41
47
  private settled = false;
48
+ private cancellation: Promise<void> | undefined;
42
49
 
43
50
  constructor(private readonly options: StreamConversationOptions<B>) {
44
51
  this.queue = new BoundedAsyncQueue(options.capacity ?? 32);
@@ -80,21 +87,80 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
80
87
  }
81
88
 
82
89
  succeed(result: BackendResult<B>): void {
83
- this.complete({ type: "success", result });
90
+ if (this.cancellation !== undefined) {
91
+ return;
92
+ }
93
+ const outcome: Outcome<B> = { type: "success", result };
94
+ if (
95
+ deferConversationSettlement(this, "success", () => {
96
+ if (this.cancellation === undefined) {
97
+ this.complete(outcome);
98
+ }
99
+ })
100
+ ) {
101
+ return;
102
+ }
103
+ this.complete(outcome);
84
104
  }
85
105
 
86
106
  fail(error: RuntimeError): void {
87
- this.complete({ type: "failed", error });
107
+ if (this.cancellation !== undefined) {
108
+ return;
109
+ }
110
+ const outcome: Outcome<B> = { type: "failed", error };
111
+ if (
112
+ deferConversationSettlement(this, "failure", () => {
113
+ if (this.cancellation === undefined) {
114
+ this.complete(outcome);
115
+ }
116
+ })
117
+ ) {
118
+ return;
119
+ }
120
+ this.complete(outcome);
88
121
  }
89
122
 
90
- async cancel(reason?: string): Promise<void> {
123
+ cancel(reason?: string): Promise<void> {
91
124
  if (this.settled) {
92
- return;
125
+ return Promise.resolve();
126
+ }
127
+ if (this.cancellation !== undefined) {
128
+ return this.cancellation;
93
129
  }
94
130
 
95
- this.abortController.abort(reason);
96
- await this.options.onCancel?.(reason);
97
- this.complete(reason === undefined ? { type: "cancelled" } : { type: "cancelled", reason });
131
+ const cancellation = Promise.withResolvers<undefined>();
132
+ this.cancellation = cancellation.promise;
133
+ registerConversationCancellationFailureHandler(this, (error: unknown) => {
134
+ this.completeCancellationFailure(error, cancellation.reject);
135
+ });
136
+
137
+ let cleanup: Promise<void>;
138
+ try {
139
+ this.abortController.abort(reason);
140
+ cleanup = Promise.resolve(this.options.onCancel?.(reason));
141
+ } catch (error) {
142
+ this.completeCancellationFailure(error, cancellation.reject);
143
+ return cancellation.promise;
144
+ }
145
+
146
+ void cleanup.then(
147
+ () => {
148
+ const outcome: Outcome<B> =
149
+ reason === undefined ? { type: "cancelled" } : { type: "cancelled", reason };
150
+ const publish = (): void => {
151
+ this.complete(outcome);
152
+ cancellation.resolve(undefined);
153
+ };
154
+ if (!deferConversationSettlement(this, "cancellation", publish)) {
155
+ publish();
156
+ }
157
+ markConversationCancellationComplete(this);
158
+ },
159
+ (error: unknown) => {
160
+ this.completeCancellationFailure(error, cancellation.reject);
161
+ }
162
+ );
163
+ return cancellation.promise;
98
164
  }
99
165
 
100
166
  private complete(outcome: Outcome<B>): void {
@@ -106,4 +172,23 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
106
172
  this.queue.close();
107
173
  this.settle(outcome);
108
174
  }
175
+
176
+ private completeCancellationFailure(
177
+ error: unknown,
178
+ rejectCancellation: (reason?: unknown) => void
179
+ ): void {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ const outcome: Outcome<B> = {
182
+ type: "failed",
183
+ error: backendFailed(this.backend, `${this.backend} cancellation cleanup failed: ${message}`)
184
+ };
185
+ const publish = (): void => {
186
+ this.complete(outcome);
187
+ rejectCancellation(error);
188
+ };
189
+ if (!deferConversationSettlement(this, "cancellation_failure", publish)) {
190
+ publish();
191
+ }
192
+ markConversationCancellationComplete(this);
193
+ }
109
194
  }
@@ -0,0 +1,127 @@
1
+ type SettlementKind = "success" | "cancellation" | "failure" | "cancellation_failure";
2
+
3
+ const settlementPriority: Readonly<Record<SettlementKind, number>> = {
4
+ success: 0,
5
+ failure: 1,
6
+ cancellation: 2,
7
+ cancellation_failure: 3
8
+ };
9
+
10
+ interface PendingSettlement {
11
+ readonly kind: SettlementKind;
12
+ readonly publish: () => void;
13
+ }
14
+
15
+ interface SettlementReservation {
16
+ depth: number;
17
+ pending?: PendingSettlement;
18
+ }
19
+
20
+ const reservations = new WeakMap<object, SettlementReservation>();
21
+
22
+ interface CancellationCompletion {
23
+ readonly promise: Promise<void>;
24
+ readonly resolve: () => void;
25
+ completed: boolean;
26
+ }
27
+
28
+ const cancellationCompletions = new WeakMap<object, CancellationCompletion>();
29
+ const cancellationFailureHandlers = new WeakMap<object, (error: unknown) => void>();
30
+
31
+ function cancellationCompletion(conversation: object): CancellationCompletion {
32
+ const existing = cancellationCompletions.get(conversation);
33
+ if (existing !== undefined) {
34
+ return existing;
35
+ }
36
+
37
+ const deferred = Promise.withResolvers<undefined>();
38
+ const created: CancellationCompletion = {
39
+ promise: deferred.promise,
40
+ resolve: () => {
41
+ deferred.resolve(undefined);
42
+ },
43
+ completed: false
44
+ };
45
+ cancellationCompletions.set(conversation, created);
46
+ return created;
47
+ }
48
+
49
+ export function observeConversationCancellationCompletion(
50
+ conversation: object
51
+ ): Promise<void> {
52
+ return cancellationCompletion(conversation).promise;
53
+ }
54
+
55
+ export function markConversationCancellationComplete(conversation: object): void {
56
+ const completion = cancellationCompletion(conversation);
57
+ if (completion.completed) {
58
+ return;
59
+ }
60
+ completion.completed = true;
61
+ completion.resolve();
62
+ }
63
+
64
+ export function registerConversationCancellationFailureHandler(
65
+ conversation: object,
66
+ handler: (error: unknown) => void
67
+ ): void {
68
+ cancellationFailureHandlers.set(conversation, handler);
69
+ }
70
+
71
+ export function reportConversationCancellationFailure(
72
+ conversation: object,
73
+ error: unknown
74
+ ): boolean {
75
+ const handler = cancellationFailureHandlers.get(conversation);
76
+ if (handler === undefined) {
77
+ return false;
78
+ }
79
+ handler(error);
80
+ return true;
81
+ }
82
+
83
+ export function reserveConversationSettlement(conversation: object): () => void {
84
+ const reservation = reservations.get(conversation) ?? { depth: 0 };
85
+ reservation.depth += 1;
86
+ reservations.set(conversation, reservation);
87
+ let released = false;
88
+
89
+ return () => {
90
+ if (released) {
91
+ return;
92
+ }
93
+ released = true;
94
+ reservation.depth -= 1;
95
+ if (reservation.depth > 0) {
96
+ return;
97
+ }
98
+
99
+ reservations.delete(conversation);
100
+ const pending = reservation.pending;
101
+ delete reservation.pending;
102
+ pending?.publish();
103
+ };
104
+ }
105
+
106
+ export function isConversationSettlementReserved(conversation: object): boolean {
107
+ return reservations.has(conversation);
108
+ }
109
+
110
+ export function deferConversationSettlement(
111
+ conversation: object,
112
+ kind: SettlementKind,
113
+ publish: () => void
114
+ ): boolean {
115
+ const reservation = reservations.get(conversation);
116
+ if (reservation === undefined) {
117
+ return false;
118
+ }
119
+
120
+ if (
121
+ reservation.pending === undefined ||
122
+ settlementPriority[kind] > settlementPriority[reservation.pending.kind]
123
+ ) {
124
+ reservation.pending = { kind, publish };
125
+ }
126
+ return true;
127
+ }
@@ -4,6 +4,13 @@ import type { SessionId } from "./brand.ts";
4
4
 
5
5
  export type BackendApprovalPolicy = "never" | "on-request" | "on-failure" | "granular" | "untrusted";
6
6
  export type BackendSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
7
+ export type CodexReasoningEffort =
8
+ | "low"
9
+ | "medium"
10
+ | "high"
11
+ | "xhigh"
12
+ | "max"
13
+ | "ultra";
7
14
 
8
15
  export interface BackendRetryConfig {
9
16
  readonly attempts: number;
@@ -17,6 +24,7 @@ export interface StructuredOutputConfig<Output = unknown> {
17
24
 
18
25
  export interface BackendConfig<B extends BackendTag = BackendTag, Output = unknown> {
19
26
  readonly model?: string;
27
+ readonly reasoningEffort?: B extends "codex" ? CodexReasoningEffort : never;
20
28
  readonly systemPrompt?: string;
21
29
  readonly approvalPolicy?: BackendApprovalPolicy;
22
30
  readonly readOnly?: boolean;
@@ -336,8 +336,10 @@ function defaultRunReporter(writeStatus: ((line: string) => void) | undefined):
336
336
  return createRunReporter({
337
337
  sinks: [
338
338
  createRunPresenter({
339
- isTTY: process.stderr.isTTY === true,
340
- writeDiagnostic: (text) => writer(text.endsWith("\n") ? text.slice(0, -1) : text),
339
+ isTTY: process.stderr.isTTY,
340
+ writeDiagnostic: (text) => {
341
+ writer(text.endsWith("\n") ? text.slice(0, -1) : text);
342
+ },
341
343
  }),
342
344
  ],
343
345
  });
@@ -186,7 +186,7 @@ export function createRunReporter(options: RunReporterOptions = {}): RunReporter
186
186
 
187
187
  export function createRunPresenter(options: RunPresenterOptions = {}): RunEventSink {
188
188
  const env = options.env ?? process.env;
189
- const isTTY = options.isTTY ?? process.stderr.isTTY === true;
189
+ const isTTY = options.isTTY ?? process.stderr.isTTY;
190
190
  const writeDiagnostic =
191
191
  options.writeDiagnostic ??
192
192
  ((text: string) => {
@@ -285,7 +285,7 @@ function formatCycleProgress(head: string, event: CycleProgressEvent): string {
285
285
 
286
286
  function prefix(options: Pick<RunPresenterOptions, "env" | "isTTY"> = {}): string {
287
287
  const env = options.env ?? process.env;
288
- const isTTY = options.isTTY ?? process.stderr.isTTY === true;
288
+ const isTTY = options.isTTY ?? process.stderr.isTTY;
289
289
  if (isTTY && !env.NO_COLOR && !env.CI) {
290
290
  return "\u001b[36morca\u001b[0m |";
291
291
  }