@hunsu/codex-runner 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/src/index.ts ADDED
@@ -0,0 +1,2263 @@
1
+ import {
2
+ createDefaultManagerConfig,
3
+ createDefaultHarness,
4
+ createDefaultMemberConfig,
5
+ DEFAULT_TEAM_PROMPT,
6
+ getHarnessMember,
7
+ getHarnessMemberConfig,
8
+ harnessSnapshotForTeam,
9
+ memberConfigFromMemberEntity,
10
+ renderPromptTemplate
11
+ } from "@hunsu/protocol";
12
+ import type { BoardProjection, Harness, HubPackageLock, HarnessSnapshot, ManagerConfig, MemberConfig, MemberPath, Destination } from "@hunsu/protocol";
13
+ import { delimiter, dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
16
+ import { createInterface, type Interface as ReadlineInterface } from "node:readline";
17
+ import { Buffer } from "node:buffer";
18
+
19
+ export const DEFAULT_TEAM_INSTRUCTION = DEFAULT_TEAM_PROMPT;
20
+
21
+ const GOAL_ASSIGNEE_OUTPUT_SCHEMA = {
22
+ type: "object",
23
+ properties: {
24
+ executorId: { type: "string" },
25
+ goal: { type: "string" }
26
+ },
27
+ required: ["executorId", "goal"],
28
+ additionalProperties: false
29
+ } as const;
30
+
31
+ const GOAL_EVALUATOR_OUTPUT_SCHEMA = {
32
+ type: ["object", "null"],
33
+ properties: {
34
+ executorId: { type: "string" },
35
+ prompt: { type: "string" }
36
+ },
37
+ required: ["executorId", "prompt"],
38
+ additionalProperties: false
39
+ } as const;
40
+
41
+ const PATH_REQUIRES_OUTPUT_SCHEMA = {
42
+ type: ["string", "array"],
43
+ items: { type: "string" }
44
+ } as const;
45
+
46
+ const GOAL_EXECUTION_PLAN_OUTPUT_SCHEMA = {
47
+ type: "object",
48
+ properties: {
49
+ kind: { type: "string", enum: ["goal"] },
50
+ stage: { type: "string", enum: ["needs_evaluation"] },
51
+ id: { type: "string" },
52
+ assignee: GOAL_ASSIGNEE_OUTPUT_SCHEMA,
53
+ evaluator: GOAL_EVALUATOR_OUTPUT_SCHEMA,
54
+ remainingAttempts: {
55
+ type: "integer",
56
+ minimum: 0
57
+ },
58
+ requires: PATH_REQUIRES_OUTPUT_SCHEMA
59
+ },
60
+ required: ["kind", "stage", "id", "assignee", "evaluator", "remainingAttempts", "requires"],
61
+ additionalProperties: false
62
+ } as const;
63
+
64
+ export const TEAM_EXECUTION_PLAN_SCHEMA = {
65
+ type: "object",
66
+ properties: {
67
+ kind: { type: "string", enum: ["queue"] },
68
+ id: { type: "string" },
69
+ items: {
70
+ type: "array",
71
+ items: GOAL_EXECUTION_PLAN_OUTPUT_SCHEMA
72
+ }
73
+ },
74
+ required: ["kind", "id", "items"],
75
+ additionalProperties: false
76
+ } as const;
77
+
78
+ export const GOAL_EVALUATION_SCHEMA = {
79
+ type: "object",
80
+ properties: {
81
+ type: { type: "string", enum: ["pass", "fail"] },
82
+ summary: { type: "string" },
83
+ reason: { type: "string" },
84
+ feedback: { type: "string" },
85
+ nextGoal: { type: "string" },
86
+ evidence: { type: "array", items: { type: "string" } }
87
+ },
88
+ required: ["type", "summary", "reason", "feedback", "nextGoal", "evidence"],
89
+ additionalProperties: false
90
+ } as const;
91
+
92
+ export type CodexRunnerThreadOptions = {
93
+ sandboxMode?: "read-only" | "workspace-write" | "danger-full-access";
94
+ approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
95
+ approvalsReviewer?: "user" | "auto_review";
96
+ networkAccessEnabled?: boolean;
97
+ additionalDirectories?: string[];
98
+ model?: string;
99
+ modelReasoningEffort?: string;
100
+ };
101
+
102
+ export type ProcessEnvironment = Record<string, string | undefined>;
103
+
104
+ export type JsonObject = Record<string, unknown>;
105
+ type JsonRpcId = string | number;
106
+ export type JsonRpcMessage = JsonObject & { id?: JsonRpcId; method?: string; params?: unknown; result?: unknown; error?: { code: number; message: string; data?: unknown } };
107
+ export type RawCodexEvent = { source: "codex.app-server"; message: JsonRpcMessage };
108
+
109
+ export type RunnerStatusPhase = "working" | "thinking" | "exploring" | "running" | "waiting" | "idle";
110
+ export type RunnerStatusChangedEvent = {
111
+ type: "runner.status.changed";
112
+ runId: string;
113
+ phase: RunnerStatusPhase;
114
+ headline: string;
115
+ detail?: string;
116
+ providerThreadId?: string;
117
+ providerTurnId?: string;
118
+ itemId?: string;
119
+ };
120
+ export type RunnerFinalEvent = { type: "runner.final"; runId: string; finalResponse: string };
121
+ export type RunnerErrorEvent = { type: "runner.error"; runId: string; error: string };
122
+ export type RunnerAppServerCommandAction =
123
+ | { type: "read"; command?: string; name?: string; path?: string }
124
+ | { type: "listFiles"; command?: string; path?: string }
125
+ | { type: "search"; command?: string; query?: string; path?: string }
126
+ | { type: "unknown"; command?: string };
127
+ export type RunnerAppServerItem = {
128
+ id?: string;
129
+ type: string;
130
+ text?: string;
131
+ summary?: string[];
132
+ content?: string[];
133
+ command?: string;
134
+ cwd?: string;
135
+ status?: string;
136
+ commandActions?: RunnerAppServerCommandAction[];
137
+ aggregatedOutput?: string;
138
+ exitCode?: number;
139
+ durationMs?: number;
140
+ changes?: unknown;
141
+ server?: string;
142
+ tool?: string;
143
+ query?: string;
144
+ raw: JsonObject;
145
+ };
146
+ export type RunnerTurnStartedEvent = {
147
+ type: "runner.turn.started";
148
+ runId: string;
149
+ providerThreadId: string;
150
+ providerTurnId: string;
151
+ turn?: JsonObject;
152
+ startedAtMs?: number;
153
+ };
154
+ export type RunnerTurnCompletedEvent = {
155
+ type: "runner.turn.completed";
156
+ runId: string;
157
+ providerThreadId: string;
158
+ providerTurnId?: string;
159
+ turn?: JsonObject;
160
+ usage?: AppServerUsage;
161
+ completedAtMs?: number;
162
+ };
163
+ export type RunnerItemStartedEvent = {
164
+ type: "runner.item.started";
165
+ runId: string;
166
+ providerThreadId?: string;
167
+ providerTurnId?: string;
168
+ itemId: string;
169
+ item: RunnerAppServerItem;
170
+ startedAtMs?: number;
171
+ };
172
+ export type RunnerItemDeltaEvent = {
173
+ type: "runner.item.delta";
174
+ runId: string;
175
+ providerThreadId?: string;
176
+ providerTurnId?: string;
177
+ itemId: string;
178
+ deltaKind: "agentMessage" | "plan" | "reasoningText" | "reasoningSummary" | "commandOutput" | "fileChangeOutput";
179
+ delta: string;
180
+ contentIndex?: number;
181
+ };
182
+ export type RunnerItemCompletedEvent = {
183
+ type: "runner.item.completed";
184
+ runId: string;
185
+ providerThreadId?: string;
186
+ providerTurnId?: string;
187
+ itemId: string;
188
+ item: RunnerAppServerItem;
189
+ completedAtMs?: number;
190
+ };
191
+ export type RunnerAppServerMessageEvent = {
192
+ type: "runner.appServer.message";
193
+ runId: string;
194
+ direction: "server-notification" | "server-request";
195
+ providerThreadId?: string;
196
+ providerTurnId?: string;
197
+ method?: string;
198
+ message: JsonRpcMessage;
199
+ };
200
+
201
+ export type TeamRunEvent =
202
+ | RunnerAppServerMessageEvent
203
+ | RunnerStatusChangedEvent
204
+ | RunnerTurnStartedEvent
205
+ | RunnerTurnCompletedEvent
206
+ | RunnerItemStartedEvent
207
+ | RunnerItemDeltaEvent
208
+ | RunnerItemCompletedEvent
209
+ | RunnerFinalEvent
210
+ | RunnerErrorEvent;
211
+
212
+ export type RunnerEvent = TeamRunEvent;
213
+
214
+ export type StartRunInput = {
215
+ runId: string;
216
+ executeId?: string;
217
+ repositoryPath: string;
218
+ worktreeHash?: string;
219
+ sourceMoveId?: string;
220
+ targetMoveOrdinal?: number;
221
+ requestGoal: string;
222
+ teamName?: string;
223
+ harness?: HarnessSnapshot;
224
+ harnessGraph?: Harness;
225
+ teamScopeId?: string;
226
+ harnessLock?: HubPackageLock;
227
+ activeDestinations: Destination[];
228
+ selectedDestinationIds?: string[];
229
+ futureConstraints?: string[];
230
+ attemptOrdinal?: number;
231
+ maxAttemptCount?: number;
232
+ previousMemberOutputs?: string[];
233
+ conversationRef?: { conversationHash: string; worktreeHash?: string };
234
+ codexThreadOptions?: CodexRunnerThreadOptions;
235
+ board: BoardProjection;
236
+ };
237
+
238
+ export type TeamPlanningInput = StartRunInput;
239
+
240
+ export type MemberPathRunInput = StartRunInput & {
241
+ memberPath: MemberPath;
242
+ dependencyOutputs?: string[];
243
+ worktreeStatus?: string;
244
+ worktreeDiff?: string;
245
+ attemptTranscript?: string;
246
+ outputSchema?: unknown;
247
+ };
248
+
249
+ export type MoveFinalizerPathOutput = {
250
+ pathId: string;
251
+ executorId: string;
252
+ goal: string;
253
+ commit?: string;
254
+ finalResponse?: string;
255
+ };
256
+
257
+ export type MoveFinalizerInput = StartRunInput & {
258
+ moveId: string;
259
+ sourceMoveCommit: string;
260
+ terminalPathCommit: string;
261
+ terminalMemberPathId?: string;
262
+ pathCommits: Record<string, string>;
263
+ pathOutputs: MoveFinalizerPathOutput[];
264
+ completionSummary: string;
265
+ diffStat?: string;
266
+ diffNameStatus?: string;
267
+ diffPatch?: string;
268
+ };
269
+
270
+ export type ResumeRunInput = TeamPlanningInput & {
271
+ providerThreadId: string;
272
+ };
273
+
274
+ export type HunsuDraftConversationMessage = {
275
+ role: "user" | "draft-agent" | "system";
276
+ text: string;
277
+ createdAt?: string;
278
+ };
279
+
280
+ export type HunsuDraftSourceSnapshot = {
281
+ sourceLineId: string;
282
+ sourceNodeId: string;
283
+ sourceMoveId?: string;
284
+ moveOrdinal?: number;
285
+ teamName?: string;
286
+ summary?: string;
287
+ destinationSummaries: Array<{
288
+ id: string;
289
+ title: string;
290
+ status: string;
291
+ }>;
292
+ };
293
+
294
+ export type HunsuDraftTurnInput = StartRunInput & {
295
+ draftSessionId: string;
296
+ manager: ManagerConfig;
297
+ sourceArtifactId?: string;
298
+ baseArtifactId?: string;
299
+ draftCheckCommand: string;
300
+ sourceSnapshot: HunsuDraftSourceSnapshot;
301
+ messages: HunsuDraftConversationMessage[];
302
+ userMessage: string;
303
+ providerThreadId?: string;
304
+ };
305
+
306
+ export type HunsuDraftSessionInput = Omit<HunsuDraftTurnInput, "userMessage">;
307
+
308
+ export type RunnerRun = {
309
+ runId: string;
310
+ provider: "codex";
311
+ providerThreadId?: string;
312
+ providerTurnId?: string;
313
+ finalResponse?: string;
314
+ rawResult?: unknown;
315
+ };
316
+
317
+ export type CodexProviderStatus = {
318
+ backend: "app-server";
319
+ available: boolean;
320
+ initialized?: unknown;
321
+ account?: unknown;
322
+ rateLimits?: unknown;
323
+ accountError?: string;
324
+ rateLimitsError?: string;
325
+ error?: string;
326
+ };
327
+
328
+ export type Runner = {
329
+ runTeamPlanning(input: TeamPlanningInput): Promise<RunnerRun>;
330
+ runMemberPath(input: MemberPathRunInput): Promise<RunnerRun>;
331
+ runMoveFinalizer(input: MoveFinalizerInput): Promise<RunnerRun>;
332
+ prepareHunsuDraftSession?(input: HunsuDraftSessionInput): Promise<RunnerRun>;
333
+ runHunsuDraftTurn(input: HunsuDraftTurnInput): Promise<RunnerRun>;
334
+ resumeRun(input: ResumeRunInput): Promise<RunnerRun>;
335
+ pauseRun(runId: string): Promise<void>;
336
+ stopRun(runId: string): Promise<void>;
337
+ providerStatus?(): Promise<CodexProviderStatus>;
338
+ events(runId: string): AsyncIterable<RunnerEvent>;
339
+ };
340
+
341
+ export const DEFAULT_CODEX_THREAD_OPTIONS = {
342
+ sandboxMode: "workspace-write",
343
+ approvalPolicy: "never",
344
+ approvalsReviewer: "user",
345
+ networkAccessEnabled: false
346
+ } satisfies CodexRunnerThreadOptions;
347
+
348
+ type RunnerEventSubscriber = {
349
+ queue: RunnerEvent[];
350
+ resolve?: () => void;
351
+ closed: boolean;
352
+ };
353
+
354
+ class RunnerEventBus {
355
+ private readonly subscribers = new Map<string, Set<RunnerEventSubscriber>>();
356
+
357
+ push(event: RunnerEvent): void {
358
+ const subscribers = this.subscribers.get(event.runId);
359
+ if (!subscribers) {
360
+ return;
361
+ }
362
+ for (const subscriber of subscribers) {
363
+ if (subscriber.closed) {
364
+ continue;
365
+ }
366
+ subscriber.queue.push(event);
367
+ subscriber.resolve?.();
368
+ subscriber.resolve = undefined;
369
+ }
370
+ }
371
+
372
+ close(runId: string): void {
373
+ const subscribers = this.subscribers.get(runId);
374
+ if (!subscribers) {
375
+ return;
376
+ }
377
+ for (const subscriber of subscribers) {
378
+ subscriber.closed = true;
379
+ subscriber.resolve?.();
380
+ subscriber.resolve = undefined;
381
+ }
382
+ }
383
+
384
+ async *events(runId: string): AsyncIterable<RunnerEvent> {
385
+ const subscriber: RunnerEventSubscriber = { queue: [], closed: false };
386
+ let subscribers = this.subscribers.get(runId);
387
+ if (!subscribers) {
388
+ subscribers = new Set();
389
+ this.subscribers.set(runId, subscribers);
390
+ }
391
+ subscribers.add(subscriber);
392
+ try {
393
+ while (true) {
394
+ const event = subscriber.queue.shift();
395
+ if (event) {
396
+ yield event;
397
+ continue;
398
+ }
399
+ if (subscriber.closed) {
400
+ return;
401
+ }
402
+ await new Promise<void>(resolve => {
403
+ subscriber.resolve = resolve;
404
+ });
405
+ }
406
+ } finally {
407
+ subscribers.delete(subscriber);
408
+ if (subscribers.size === 0) {
409
+ this.subscribers.delete(runId);
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ type JsonRpcRequestHandler = (message: JsonRpcMessage) => Promise<unknown> | unknown;
416
+ type JsonRpcNotificationHandler = (message: JsonRpcMessage) => void;
417
+
418
+ export type CodexAppServerTransport = {
419
+ send(message: JsonObject): void;
420
+ close(): void;
421
+ onMessage(handler: (message: JsonRpcMessage) => void): () => void;
422
+ onError(handler: (error: Error) => void): () => void;
423
+ onClose(handler: () => void): () => void;
424
+ };
425
+
426
+ export type CodexAppServerClientOptions = {
427
+ command?: string;
428
+ args?: string[];
429
+ cwd?: string;
430
+ environment?: ProcessEnvironment;
431
+ requestTimeoutMs?: number;
432
+ clientInfo?: { name: string; title: string | null; version: string };
433
+ transportFactory?: () => CodexAppServerTransport;
434
+ handleServerRequest?: JsonRpcRequestHandler;
435
+ };
436
+
437
+ export type CodexAppServerRunnerOptions = {
438
+ client?: CodexAppServerClient;
439
+ clientOptions?: CodexAppServerClientOptions;
440
+ threadOptions?: CodexRunnerThreadOptions;
441
+ environment?: ProcessEnvironment;
442
+ };
443
+
444
+ export type AppServerUsage = {
445
+ input_tokens: number;
446
+ cached_input_tokens: number;
447
+ output_tokens: number;
448
+ reasoning_output_tokens: number;
449
+ };
450
+
451
+ type ActiveAppServerTurn = {
452
+ runId: string;
453
+ threadId: string;
454
+ turnId?: string;
455
+ finalResponse?: string;
456
+ usage?: AppServerUsage;
457
+ rawEvents: RawCodexEvent[];
458
+ activeCommandExecutionIds: Set<string>;
459
+ backgroundTerminalCleanupRequested: boolean;
460
+ finalEmitted: boolean;
461
+ completed: boolean;
462
+ resolve: (run: RunnerRun) => void;
463
+ reject: (error: Error) => void;
464
+ };
465
+
466
+ class CodexAppServerRequestError extends Error {
467
+ readonly code: number;
468
+ readonly data?: unknown;
469
+
470
+ constructor(code: number, message: string, data?: unknown) {
471
+ super(message);
472
+ this.code = code;
473
+ this.data = data;
474
+ }
475
+ }
476
+
477
+ class CodexAppServerProcessTransport implements CodexAppServerTransport {
478
+ private readonly child: ChildProcessWithoutNullStreams;
479
+ private readonly stdout: ReadlineInterface;
480
+ private readonly messageHandlers = new Set<(message: JsonRpcMessage) => void>();
481
+ private readonly errorHandlers = new Set<(error: Error) => void>();
482
+ private readonly closeHandlers = new Set<() => void>();
483
+
484
+ constructor(options: Required<Pick<CodexAppServerClientOptions, "command" | "args" | "environment">> & Pick<CodexAppServerClientOptions, "cwd">) {
485
+ this.child = spawn(options.command, options.args, {
486
+ cwd: options.cwd,
487
+ env: createCodexEnvironment(options.environment)
488
+ });
489
+ this.stdout = createInterface({ input: this.child.stdout });
490
+ this.stdout.on("line", line => this.handleLine(line));
491
+ this.child.stderr.on("data", () => undefined);
492
+ this.child.once("error", error => this.emitError(error));
493
+ this.child.once("close", () => this.emitClose());
494
+ }
495
+
496
+ send(message: JsonObject): void {
497
+ if (this.child.stdin.destroyed || !this.child.stdin.writable) {
498
+ throw new Error("Codex app-server stdin is closed");
499
+ }
500
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
501
+ }
502
+
503
+ close(): void {
504
+ this.stdout.close();
505
+ if (!this.child.killed) {
506
+ this.child.kill("SIGTERM");
507
+ }
508
+ }
509
+
510
+ onMessage(handler: (message: JsonRpcMessage) => void): () => void {
511
+ this.messageHandlers.add(handler);
512
+ return () => this.messageHandlers.delete(handler);
513
+ }
514
+
515
+ onError(handler: (error: Error) => void): () => void {
516
+ this.errorHandlers.add(handler);
517
+ return () => this.errorHandlers.delete(handler);
518
+ }
519
+
520
+ onClose(handler: () => void): () => void {
521
+ this.closeHandlers.add(handler);
522
+ return () => this.closeHandlers.delete(handler);
523
+ }
524
+
525
+ private handleLine(line: string): void {
526
+ if (!line.trim()) {
527
+ return;
528
+ }
529
+ try {
530
+ const message = JSON.parse(line) as JsonRpcMessage;
531
+ for (const handler of this.messageHandlers) {
532
+ handler(message);
533
+ }
534
+ } catch (error) {
535
+ this.emitError(new Error(`Invalid Codex app-server JSON-RPC message: ${error instanceof Error ? error.message : String(error)}`));
536
+ }
537
+ }
538
+
539
+ private emitError(error: Error): void {
540
+ for (const handler of this.errorHandlers) {
541
+ handler(error);
542
+ }
543
+ }
544
+
545
+ private emitClose(): void {
546
+ for (const handler of this.closeHandlers) {
547
+ handler();
548
+ }
549
+ }
550
+ }
551
+
552
+ export class CodexAppServerClient {
553
+ private readonly requestTimeoutMs: number;
554
+ private readonly transportFactory: () => CodexAppServerTransport;
555
+ private readonly clientInfo: { name: string; title: string | null; version: string };
556
+ private serverRequestHandler: JsonRpcRequestHandler;
557
+ private transport?: CodexAppServerTransport;
558
+ private startPromise?: Promise<void>;
559
+ private initialized?: unknown;
560
+ private nextId = 1;
561
+ private readonly pending = new Map<JsonRpcId, {
562
+ method: string;
563
+ resolve: (result: unknown) => void;
564
+ reject: (error: Error) => void;
565
+ timer: NodeJS.Timeout;
566
+ }>();
567
+ private readonly notificationHandlers = new Set<JsonRpcNotificationHandler>();
568
+
569
+ constructor(options: CodexAppServerClientOptions = {}) {
570
+ const environment = options.environment ?? {};
571
+ const command = options.command ?? "codex";
572
+ const args = options.args ?? ["app-server", "--stdio"];
573
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 60_000;
574
+ this.clientInfo = options.clientInfo ?? { name: "hunsu-studio", title: "Hunsu Studio", version: "0.1.0" };
575
+ this.transportFactory = options.transportFactory ?? (() => new CodexAppServerProcessTransport({
576
+ command,
577
+ args,
578
+ environment,
579
+ cwd: options.cwd
580
+ }));
581
+ this.serverRequestHandler = options.handleServerRequest ?? defaultAppServerRequestHandler;
582
+ }
583
+
584
+ setServerRequestHandler(handler: JsonRpcRequestHandler): void {
585
+ this.serverRequestHandler = handler;
586
+ }
587
+
588
+ async initialize(): Promise<unknown> {
589
+ await this.ensureStarted();
590
+ return this.initialized;
591
+ }
592
+
593
+ async request(method: string, params?: unknown, options: { timeoutMs?: number } = {}): Promise<unknown> {
594
+ await this.ensureStarted();
595
+ return this.requestRaw(method, params, options.timeoutMs);
596
+ }
597
+
598
+ notify(method: string, params?: unknown): void {
599
+ this.transport?.send(params === undefined ? { method } : { method, params });
600
+ }
601
+
602
+ onNotification(handler: JsonRpcNotificationHandler): () => void {
603
+ this.notificationHandlers.add(handler);
604
+ return () => this.notificationHandlers.delete(handler);
605
+ }
606
+
607
+ close(): void {
608
+ this.disposeTransport(new Error("Codex app-server client closed"), true);
609
+ }
610
+
611
+ private async ensureStarted(): Promise<void> {
612
+ if (this.transport && this.initialized) {
613
+ return;
614
+ }
615
+ if (this.startPromise) {
616
+ return this.startPromise;
617
+ }
618
+ this.startPromise = this.start();
619
+ try {
620
+ await this.startPromise;
621
+ } finally {
622
+ this.startPromise = undefined;
623
+ }
624
+ }
625
+
626
+ private async start(): Promise<void> {
627
+ const transport = this.transportFactory();
628
+ this.transport = transport;
629
+ transport.onMessage(message => this.handleMessage(message));
630
+ transport.onError(error => this.disposeTransport(error, true));
631
+ transport.onClose(() => {
632
+ this.disposeTransport(new Error("Codex app-server process exited"), false);
633
+ });
634
+ this.initialized = await this.requestRaw("initialize", {
635
+ clientInfo: this.clientInfo,
636
+ capabilities: null
637
+ }, this.requestTimeoutMs);
638
+ this.notify("initialized");
639
+ }
640
+
641
+ private requestRaw(method: string, params?: unknown, timeoutMs = this.requestTimeoutMs): Promise<unknown> {
642
+ const transport = this.transport;
643
+ if (!transport) {
644
+ throw new Error("Codex app-server transport is not started");
645
+ }
646
+ const id = `hunsu-${this.nextId++}`;
647
+ return new Promise<unknown>((resolve, reject) => {
648
+ const timer = setTimeout(() => {
649
+ this.pending.delete(id);
650
+ const error = new Error(`Codex app-server request timed out: ${method}`);
651
+ reject(error);
652
+ this.disposeTransport(error, true);
653
+ }, timeoutMs);
654
+ this.pending.set(id, { method, resolve, reject, timer });
655
+ try {
656
+ transport.send(params === undefined ? { id, method } : { id, method, params });
657
+ } catch (error) {
658
+ clearTimeout(timer);
659
+ this.pending.delete(id);
660
+ reject(error instanceof Error ? error : new Error(String(error)));
661
+ }
662
+ });
663
+ }
664
+
665
+ private handleMessage(message: JsonRpcMessage): void {
666
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined) && !message.method) {
667
+ this.handleResponse(message);
668
+ return;
669
+ }
670
+ if (message.id !== undefined && typeof message.method === "string") {
671
+ void this.handleServerRequest(message);
672
+ return;
673
+ }
674
+ if (typeof message.method === "string") {
675
+ for (const handler of this.notificationHandlers) {
676
+ handler(message);
677
+ }
678
+ }
679
+ }
680
+
681
+ private handleResponse(message: JsonRpcMessage): void {
682
+ const pending = this.pending.get(message.id as JsonRpcId);
683
+ if (!pending) {
684
+ return;
685
+ }
686
+ clearTimeout(pending.timer);
687
+ this.pending.delete(message.id as JsonRpcId);
688
+ if (message.error) {
689
+ pending.reject(new Error(`Codex app-server ${pending.method} failed: ${message.error.message}`));
690
+ return;
691
+ }
692
+ pending.resolve(message.result);
693
+ }
694
+
695
+ private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
696
+ try {
697
+ const result = await this.serverRequestHandler(message);
698
+ this.transport?.send({ id: message.id as JsonRpcId, result });
699
+ } catch (error) {
700
+ const requestError = error instanceof CodexAppServerRequestError
701
+ ? error
702
+ : new CodexAppServerRequestError(-32603, error instanceof Error ? error.message : String(error));
703
+ this.transport?.send({
704
+ id: message.id as JsonRpcId,
705
+ error: {
706
+ code: requestError.code,
707
+ message: requestError.message,
708
+ data: requestError.data
709
+ }
710
+ });
711
+ }
712
+ }
713
+
714
+ private rejectPending(error: Error): void {
715
+ for (const [id, pending] of this.pending) {
716
+ clearTimeout(pending.timer);
717
+ pending.reject(error);
718
+ this.pending.delete(id);
719
+ }
720
+ }
721
+
722
+ private disposeTransport(error: Error, closeTransport: boolean): void {
723
+ const transport = this.transport;
724
+ this.transport = undefined;
725
+ this.initialized = undefined;
726
+ this.rejectPending(error);
727
+ if (closeTransport) {
728
+ transport?.close();
729
+ }
730
+ }
731
+ }
732
+
733
+ export class CodexAppServerRunner implements Runner {
734
+ private readonly eventBus = new RunnerEventBus();
735
+ private readonly client: CodexAppServerClient;
736
+ private readonly threadOptions: CodexRunnerThreadOptions;
737
+ private readonly activeByRunId = new Map<string, ActiveAppServerTurn>();
738
+ private readonly activeByThreadId = new Map<string, ActiveAppServerTurn>();
739
+ private readonly activeByTurnId = new Map<string, ActiveAppServerTurn>();
740
+
741
+ constructor(options: CodexAppServerRunnerOptions = {}) {
742
+ this.threadOptions = {
743
+ ...DEFAULT_CODEX_THREAD_OPTIONS,
744
+ ...options.threadOptions
745
+ };
746
+ this.client = options.client ?? new CodexAppServerClient({
747
+ ...options.clientOptions,
748
+ environment: options.clientOptions?.environment ?? options.environment ?? {}
749
+ });
750
+ this.client.setServerRequestHandler(message => this.handleServerRequest(message));
751
+ this.client.onNotification(message => this.handleNotification(message));
752
+ }
753
+
754
+ async runTeamPlanning(input: TeamPlanningInput): Promise<RunnerRun> {
755
+ const config = teamPlanningConfigFor(input);
756
+ return this.executeTurn(input, config, buildTeamPlanningPrompt(input), undefined, true, TEAM_EXECUTION_PLAN_SCHEMA);
757
+ }
758
+
759
+ async runMemberPath(input: MemberPathRunInput): Promise<RunnerRun> {
760
+ const member = memberConfigFor(input, input.memberPath.executorId);
761
+ return this.executeTurn(input, member, buildMemberPathPrompt(input), undefined, false, input.outputSchema);
762
+ }
763
+
764
+ async runMoveFinalizer(input: MoveFinalizerInput): Promise<RunnerRun> {
765
+ const config = moveFinalizerConfigFor();
766
+ return this.executeTurn(input, config, buildMoveFinalizerPrompt(input), undefined, true);
767
+ }
768
+
769
+ async runHunsuDraftTurn(input: HunsuDraftTurnInput): Promise<RunnerRun> {
770
+ const config = hunsuDraftConfigFor(input.manager);
771
+ return this.executeTurn(input, config, buildHunsuDraftPrompt(input), input.providerThreadId, false, undefined, { reusePreparedThread: true });
772
+ }
773
+
774
+ async prepareHunsuDraftSession(input: HunsuDraftSessionInput): Promise<RunnerRun> {
775
+ const config = hunsuDraftConfigFor(input.manager);
776
+ const providerThreadId = await this.prepareThread(input, config, input.providerThreadId, false);
777
+ return {
778
+ runId: input.runId,
779
+ provider: "codex",
780
+ providerThreadId
781
+ };
782
+ }
783
+
784
+ async resumeRun(input: ResumeRunInput): Promise<RunnerRun> {
785
+ const config = teamPlanningConfigFor(input);
786
+ return this.executeTurn(input, config, buildTeamPlanningPrompt(input), input.providerThreadId, true, TEAM_EXECUTION_PLAN_SCHEMA);
787
+ }
788
+
789
+ async pauseRun(runId: string): Promise<void> {
790
+ this.eventBus.push({ type: "runner.status.changed", runId, phase: "waiting", headline: "Pause requested", detail: "Codex app-server will stop after the current turn boundary." });
791
+ }
792
+
793
+ async stopRun(runId: string): Promise<void> {
794
+ const active = this.activeByRunId.get(runId);
795
+ this.eventBus.push({ type: "runner.status.changed", runId, phase: "waiting", headline: "Stop requested" });
796
+ if (active?.threadId && active.turnId) {
797
+ try {
798
+ await this.client.request("turn/interrupt", { threadId: active.threadId, turnId: active.turnId }, { timeoutMs: 10_000 });
799
+ } catch (error) {
800
+ this.eventBus.push({ type: "runner.error", runId, error: `Codex app-server interrupt failed: ${error instanceof Error ? error.message : String(error)}` });
801
+ }
802
+ active.completed = true;
803
+ active.reject(new Error("Codex turn interrupted by Studio"));
804
+ return;
805
+ }
806
+ if (active) {
807
+ active.completed = true;
808
+ this.client.close();
809
+ active.reject(new Error("Codex turn interrupted by Studio before app-server returned a turn id"));
810
+ return;
811
+ }
812
+ this.eventBus.close(runId);
813
+ }
814
+
815
+ async providerStatus(): Promise<CodexProviderStatus> {
816
+ try {
817
+ const initialized = await this.client.initialize();
818
+ const status: CodexProviderStatus = { backend: "app-server", available: true, initialized };
819
+ try {
820
+ status.account = await this.client.request("account/read", { refreshToken: false });
821
+ } catch (error) {
822
+ status.accountError = error instanceof Error ? error.message : String(error);
823
+ }
824
+ try {
825
+ status.rateLimits = await this.client.request("account/rateLimits/read");
826
+ } catch (error) {
827
+ status.rateLimitsError = error instanceof Error ? error.message : String(error);
828
+ }
829
+ const errors = [status.accountError, status.rateLimitsError].filter(Boolean);
830
+ if (errors.length > 0) {
831
+ status.error = errors.join("; ");
832
+ }
833
+ return status;
834
+ } catch (error) {
835
+ return { backend: "app-server", available: false, error: error instanceof Error ? error.message : String(error) };
836
+ }
837
+ }
838
+
839
+ events(runId: string): AsyncIterable<RunnerEvent> {
840
+ return this.eventBus.events(runId);
841
+ }
842
+
843
+ private async executeTurn(
844
+ input: StartRunInput,
845
+ config: MemberConfig,
846
+ prompt: string,
847
+ providerThreadId?: string,
848
+ readOnly = false,
849
+ outputSchema?: unknown,
850
+ options: { reusePreparedThread?: boolean } = {}
851
+ ): Promise<RunnerRun> {
852
+ const shouldReusePreparedThread = options.reusePreparedThread === true && providerThreadId !== undefined && !outputSchema;
853
+ try {
854
+ const threadId = shouldReusePreparedThread
855
+ ? providerThreadId
856
+ : await this.prepareThread(input, config, providerThreadId, readOnly, outputSchema);
857
+ if (shouldReusePreparedThread) {
858
+ this.eventBus.push({
859
+ type: "runner.status.changed",
860
+ runId: input.runId,
861
+ phase: "working",
862
+ headline: "Prepared thread reused",
863
+ detail: threadId,
864
+ providerThreadId: threadId
865
+ });
866
+ }
867
+
868
+ try {
869
+ return await this.startTurn(input, config, threadId, prompt, readOnly, outputSchema);
870
+ } catch (error) {
871
+ if (!shouldReusePreparedThread) {
872
+ throw error;
873
+ }
874
+ const message = error instanceof Error ? error.message : String(error);
875
+ this.eventBus.push({
876
+ type: "runner.status.changed",
877
+ runId: input.runId,
878
+ phase: "working",
879
+ headline: "Prepared thread reuse failed",
880
+ detail: message,
881
+ providerThreadId
882
+ });
883
+ const resumedThreadId = await this.prepareThread(input, config, providerThreadId, readOnly, outputSchema);
884
+ return await this.startTurn(input, config, resumedThreadId, prompt, readOnly, outputSchema);
885
+ }
886
+ } catch (error) {
887
+ const message = error instanceof Error ? error.message : String(error);
888
+ this.eventBus.push({ type: "runner.error", runId: input.runId, error: message });
889
+ throw error;
890
+ } finally {
891
+ this.eventBus.close(input.runId);
892
+ }
893
+ }
894
+
895
+ private async startTurn(
896
+ input: StartRunInput,
897
+ config: MemberConfig,
898
+ threadId: string,
899
+ prompt: string,
900
+ readOnly: boolean,
901
+ outputSchema?: unknown
902
+ ): Promise<RunnerRun> {
903
+ let active: ActiveAppServerTurn | undefined;
904
+ try {
905
+ return await new Promise<RunnerRun>((resolve, reject) => {
906
+ active = {
907
+ runId: input.runId,
908
+ threadId,
909
+ rawEvents: [],
910
+ activeCommandExecutionIds: new Set(),
911
+ backgroundTerminalCleanupRequested: false,
912
+ finalEmitted: false,
913
+ completed: false,
914
+ resolve,
915
+ reject
916
+ };
917
+ this.trackActiveTurn(active);
918
+ this.client.request("turn/start", this.turnStartParamsFor(input, config, threadId, prompt, readOnly, outputSchema), { timeoutMs: 60_000 })
919
+ .then(turnResponse => {
920
+ const turn = readRecord(turnResponse, "turn");
921
+ if (!active || active.completed) {
922
+ return;
923
+ }
924
+ const turnId = readString(turn, "id");
925
+ if (turnId) {
926
+ this.setActiveTurnId(active, turnId);
927
+ }
928
+ if (turn && readString(turn, "status") !== "inProgress") {
929
+ this.completeActiveTurn(active, turn);
930
+ }
931
+ })
932
+ .catch(error => {
933
+ if (!active?.completed) {
934
+ reject(error instanceof Error ? error : new Error(String(error)));
935
+ }
936
+ });
937
+ });
938
+ } finally {
939
+ if (active) {
940
+ this.untrackActiveTurn(active);
941
+ }
942
+ }
943
+ }
944
+
945
+ private async prepareThread(
946
+ input: StartRunInput,
947
+ config: MemberConfig,
948
+ providerThreadId: string | undefined,
949
+ readOnly: boolean,
950
+ outputSchema?: unknown
951
+ ): Promise<string> {
952
+ const threadResponse = providerThreadId
953
+ ? await this.client.request("thread/resume", this.threadResumeParamsFor(input, config, providerThreadId, readOnly))
954
+ : await this.client.request("thread/start", this.threadStartParamsFor(input, config, readOnly));
955
+ const threadId = readNestedString(threadResponse, ["thread", "id"]) ?? providerThreadId;
956
+ if (!threadId) {
957
+ throw new Error("Codex app-server did not return a thread id");
958
+ }
959
+ this.eventBus.push({
960
+ type: "runner.status.changed",
961
+ runId: input.runId,
962
+ phase: "working",
963
+ headline: "Thread ready",
964
+ detail: threadId,
965
+ providerThreadId: threadId
966
+ });
967
+
968
+ if (outputSchema) {
969
+ await this.client.request("thread/goal/clear", { threadId }, { timeoutMs: 60_000 });
970
+ this.eventBus.push({
971
+ type: "runner.status.changed",
972
+ runId: input.runId,
973
+ phase: "working",
974
+ headline: "Provider goal cleared",
975
+ providerThreadId: threadId
976
+ });
977
+ } else {
978
+ await this.client.request("thread/goal/set", this.threadGoalSetParamsFor(input, config, threadId), { timeoutMs: 60_000 });
979
+ this.eventBus.push({
980
+ type: "runner.status.changed",
981
+ runId: input.runId,
982
+ phase: "working",
983
+ headline: "Provider goal set",
984
+ providerThreadId: threadId
985
+ });
986
+ }
987
+ return threadId;
988
+ }
989
+
990
+ private threadStartParamsFor(input: StartRunInput, config: MemberConfig, readOnly: boolean): JsonObject {
991
+ const options = this.effectiveThreadOptions(input, config, readOnly);
992
+ return {
993
+ cwd: input.repositoryPath,
994
+ approvalPolicy: options.approvalPolicy,
995
+ approvalsReviewer: options.approvalsReviewer ?? "user",
996
+ sandbox: options.sandboxMode,
997
+ model: options.model ?? null,
998
+ serviceTier: config.serviceTier === "fast" ? "fast" : null,
999
+ ephemeral: false,
1000
+ threadSource: "user"
1001
+ };
1002
+ }
1003
+
1004
+ private threadResumeParamsFor(input: StartRunInput, config: MemberConfig, providerThreadId: string, readOnly: boolean): JsonObject {
1005
+ const options = this.effectiveThreadOptions(input, config, readOnly);
1006
+ return {
1007
+ threadId: providerThreadId,
1008
+ cwd: input.repositoryPath,
1009
+ approvalPolicy: options.approvalPolicy,
1010
+ approvalsReviewer: options.approvalsReviewer ?? "user",
1011
+ sandbox: options.sandboxMode,
1012
+ model: options.model ?? null,
1013
+ serviceTier: config.serviceTier === "fast" ? "fast" : null
1014
+ };
1015
+ }
1016
+
1017
+ private threadGoalSetParamsFor(input: StartRunInput, config: MemberConfig, threadId: string): JsonObject {
1018
+ return {
1019
+ threadId,
1020
+ objective: renderProviderGoal(input, config)
1021
+ };
1022
+ }
1023
+
1024
+ private turnStartParamsFor(
1025
+ input: StartRunInput,
1026
+ config: MemberConfig,
1027
+ threadId: string,
1028
+ prompt: string,
1029
+ readOnly: boolean,
1030
+ outputSchema?: unknown
1031
+ ): JsonObject {
1032
+ const options = this.effectiveThreadOptions(input, config, readOnly);
1033
+ return {
1034
+ threadId,
1035
+ input: [{ type: "text", text: prompt, text_elements: [] }],
1036
+ cwd: input.repositoryPath,
1037
+ approvalPolicy: options.approvalPolicy,
1038
+ approvalsReviewer: options.approvalsReviewer ?? "user",
1039
+ sandboxPolicy: appServerSandboxPolicyFor(input.repositoryPath, options, readOnly),
1040
+ model: options.model ?? null,
1041
+ serviceTier: config.serviceTier === "fast" ? "fast" : null,
1042
+ effort: options.modelReasoningEffort ?? null,
1043
+ outputSchema: outputSchema ?? null
1044
+ };
1045
+ }
1046
+
1047
+ private effectiveThreadOptions(input: StartRunInput, config: MemberConfig, readOnly: boolean): CodexRunnerThreadOptions {
1048
+ const memberOptions = threadOptionsForMember(config);
1049
+ const options: CodexRunnerThreadOptions = {
1050
+ ...this.threadOptions,
1051
+ ...input.codexThreadOptions,
1052
+ ...memberOptions
1053
+ };
1054
+ if (readOnly) {
1055
+ options.networkAccessEnabled = input.codexThreadOptions?.networkAccessEnabled ?? this.threadOptions.networkAccessEnabled ?? memberOptions.networkAccessEnabled;
1056
+ options.sandboxMode = "read-only";
1057
+ options.approvalPolicy = "never";
1058
+ options.approvalsReviewer = "user";
1059
+ }
1060
+ return options;
1061
+ }
1062
+
1063
+ private handleNotification(message: JsonRpcMessage): void {
1064
+ const active = activeTurnForMessage(message, this.activeByThreadId, this.activeByTurnId);
1065
+ if (!active) {
1066
+ return;
1067
+ }
1068
+ active.rawEvents.push(rawCodexEvent(message));
1069
+ const params = isRecord(message.params) ? message.params : {};
1070
+ const messageTurnId = readString(params, "turnId") ?? readNestedString(params, ["turn", "id"]);
1071
+ this.eventBus.push({
1072
+ type: "runner.appServer.message",
1073
+ runId: active.runId,
1074
+ direction: "server-notification",
1075
+ providerThreadId: active.threadId,
1076
+ providerTurnId: messageTurnId ?? active.turnId,
1077
+ method: message.method,
1078
+ message
1079
+ });
1080
+ if (messageTurnId && messageTurnId !== active.turnId) {
1081
+ this.setActiveTurnId(active, messageTurnId);
1082
+ }
1083
+ if (message.method === "item/started" || message.method === "item/completed") {
1084
+ this.trackCommandExecutionItem(active, message.method, readRecord(params, "item"));
1085
+ }
1086
+ const effects = interpretAppServerNotification({
1087
+ runId: active.runId,
1088
+ providerThreadId: active.threadId,
1089
+ providerTurnId: messageTurnId ?? active.turnId
1090
+ }, message);
1091
+ for (const effect of effects) {
1092
+ switch (effect.kind) {
1093
+ case "setTurnId":
1094
+ this.setActiveTurnId(active, effect.turnId);
1095
+ break;
1096
+ case "usage":
1097
+ active.usage = effect.usage;
1098
+ break;
1099
+ case "teamEvent":
1100
+ this.eventBus.push(effect.event);
1101
+ break;
1102
+ case "finalResponse":
1103
+ active.finalResponse = effect.finalResponse;
1104
+ this.requestBackgroundTerminalCleanupIfNeeded(active);
1105
+ break;
1106
+ case "completeTurn":
1107
+ this.completeActiveTurn(active, effect.turn);
1108
+ break;
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ private trackCommandExecutionItem(active: ActiveAppServerTurn, method: "item/started" | "item/completed", item: JsonObject | undefined): void {
1114
+ if (readString(item, "type") !== "commandExecution") {
1115
+ return;
1116
+ }
1117
+ const itemId = appServerItemId(item);
1118
+ if (!itemId) {
1119
+ return;
1120
+ }
1121
+ const status = readString(item, "status");
1122
+ if (method === "item/completed" || status === "completed" || status === "failed" || status === "declined") {
1123
+ active.activeCommandExecutionIds.delete(itemId);
1124
+ return;
1125
+ }
1126
+ active.activeCommandExecutionIds.add(itemId);
1127
+ }
1128
+
1129
+ private requestBackgroundTerminalCleanupIfNeeded(active: ActiveAppServerTurn): void {
1130
+ if (active.backgroundTerminalCleanupRequested || active.activeCommandExecutionIds.size === 0) {
1131
+ return;
1132
+ }
1133
+ active.backgroundTerminalCleanupRequested = true;
1134
+ void this.client.request("thread/backgroundTerminals/clean", { threadId: active.threadId }, { timeoutMs: 10_000 }).catch(() => undefined);
1135
+ }
1136
+
1137
+ private completeActiveTurn(active: ActiveAppServerTurn, turn: JsonObject | undefined): void {
1138
+ if (active.completed) {
1139
+ return;
1140
+ }
1141
+ active.completed = true;
1142
+ const turnId = readString(turn, "id") ?? active.turnId;
1143
+ if (turnId) {
1144
+ this.setActiveTurnId(active, turnId);
1145
+ }
1146
+ const status = readString(turn, "status");
1147
+ if (status === "failed") {
1148
+ const error = readRecord(turn, "error");
1149
+ const message = readString(error, "message") ?? "Codex app-server turn failed";
1150
+ this.eventBus.push({ type: "runner.error", runId: active.runId, error: message });
1151
+ active.reject(new Error(message));
1152
+ return;
1153
+ }
1154
+ if (status === "interrupted") {
1155
+ active.reject(new Error("Codex app-server turn interrupted"));
1156
+ return;
1157
+ }
1158
+ const finalResponse = active.finalResponse ?? finalResponseFromAppServerTurn(turn);
1159
+ if (finalResponse && !active.finalEmitted) {
1160
+ active.finalEmitted = true;
1161
+ this.eventBus.push({ type: "runner.final", runId: active.runId, finalResponse });
1162
+ }
1163
+ active.resolve({
1164
+ runId: active.runId,
1165
+ provider: "codex",
1166
+ providerThreadId: active.threadId,
1167
+ providerTurnId: active.turnId,
1168
+ finalResponse,
1169
+ rawResult: {
1170
+ rawEvents: active.rawEvents,
1171
+ finalResponse,
1172
+ usage: active.usage,
1173
+ turn
1174
+ }
1175
+ });
1176
+ }
1177
+
1178
+ private trackActiveTurn(active: ActiveAppServerTurn): void {
1179
+ this.activeByRunId.set(active.runId, active);
1180
+ this.activeByThreadId.set(active.threadId, active);
1181
+ if (active.turnId) {
1182
+ this.activeByTurnId.set(active.turnId, active);
1183
+ }
1184
+ }
1185
+
1186
+ private setActiveTurnId(active: ActiveAppServerTurn, turnId: string): void {
1187
+ if (active.turnId && active.turnId !== turnId) {
1188
+ this.activeByTurnId.delete(active.turnId);
1189
+ }
1190
+ active.turnId = turnId;
1191
+ this.activeByTurnId.set(turnId, active);
1192
+ }
1193
+
1194
+ private untrackActiveTurn(active: ActiveAppServerTurn): void {
1195
+ this.activeByRunId.delete(active.runId);
1196
+ this.activeByThreadId.delete(active.threadId);
1197
+ if (active.turnId) {
1198
+ this.activeByTurnId.delete(active.turnId);
1199
+ }
1200
+ }
1201
+
1202
+ private async handleServerRequest(message: JsonRpcMessage): Promise<unknown> {
1203
+ const params = isRecord(message.params) ? message.params : {};
1204
+ const active = activeTurnForMessage(message, this.activeByThreadId, this.activeByTurnId);
1205
+ active?.rawEvents.push(rawCodexEvent(message));
1206
+ const runId = active?.runId;
1207
+ if (runId) {
1208
+ this.eventBus.push({
1209
+ type: "runner.appServer.message",
1210
+ runId,
1211
+ direction: "server-request",
1212
+ providerThreadId: active?.threadId,
1213
+ providerTurnId: active?.turnId,
1214
+ method: message.method,
1215
+ message
1216
+ });
1217
+ }
1218
+ const approvalSummary = appServerApprovalSummary(message.method, params);
1219
+ if (runId && approvalSummary) {
1220
+ this.eventBus.push({ type: "runner.status.changed", runId, phase: "waiting", headline: "Approval requested", detail: approvalSummary, providerThreadId: active?.threadId, providerTurnId: active?.turnId });
1221
+ }
1222
+ if (message.method === "item/permissions/requestApproval") {
1223
+ if (active?.threadId && active.turnId) {
1224
+ void this.client.request("turn/interrupt", { threadId: active.threadId, turnId: active.turnId }, { timeoutMs: 10_000 }).catch(() => undefined);
1225
+ }
1226
+ if (runId) {
1227
+ this.eventBus.push({ type: "runner.error", runId, error: "Codex app-server requested additional permissions; Hunsu declined and interrupted the turn." });
1228
+ }
1229
+ if (active && !active.completed) {
1230
+ active.completed = true;
1231
+ active.reject(new Error("Codex app-server requested additional permissions; Hunsu declined and interrupted the turn."));
1232
+ }
1233
+ }
1234
+ return defaultAppServerRequestHandler(message);
1235
+ }
1236
+ }
1237
+
1238
+ export function createDefaultCodexRunner(options: CodexAppServerRunnerOptions = {}): Runner {
1239
+ return new CodexAppServerRunner(options);
1240
+ }
1241
+
1242
+ function defaultAppServerRequestHandler(message: JsonRpcMessage): unknown {
1243
+ switch (message.method) {
1244
+ case "item/commandExecution/requestApproval":
1245
+ return { decision: "decline" };
1246
+ case "item/fileChange/requestApproval":
1247
+ return { decision: "decline" };
1248
+ case "execCommandApproval":
1249
+ case "applyPatchApproval":
1250
+ return { decision: "denied" };
1251
+ case "item/permissions/requestApproval":
1252
+ return { permissions: {}, scope: "turn", strictAutoReview: true };
1253
+ default:
1254
+ throw new CodexAppServerRequestError(-32601, `Unsupported Codex app-server request: ${message.method ?? "unknown"}`);
1255
+ }
1256
+ }
1257
+
1258
+ function appServerSandboxPolicyFor(repositoryPath: string, options: CodexRunnerThreadOptions, readOnly: boolean): JsonObject {
1259
+ const networkAccess = options.networkAccessEnabled === true;
1260
+ if (readOnly || options.sandboxMode === "read-only") {
1261
+ return { type: "readOnly", networkAccess };
1262
+ }
1263
+ if (options.sandboxMode === "danger-full-access") {
1264
+ return { type: "dangerFullAccess", networkAccess };
1265
+ }
1266
+ return {
1267
+ type: "workspaceWrite",
1268
+ writableRoots: [repositoryPath],
1269
+ networkAccess,
1270
+ excludeTmpdirEnvVar: false,
1271
+ excludeSlashTmp: false
1272
+ };
1273
+ }
1274
+
1275
+ function activeTurnForMessage(
1276
+ message: JsonRpcMessage,
1277
+ byThreadId: Map<string, ActiveAppServerTurn>,
1278
+ byTurnId: Map<string, ActiveAppServerTurn>
1279
+ ): ActiveAppServerTurn | undefined {
1280
+ const params = isRecord(message.params) ? message.params : {};
1281
+ const turnId = readString(params, "turnId") ?? readNestedString(params, ["turn", "id"]);
1282
+ if (turnId) {
1283
+ const active = byTurnId.get(turnId);
1284
+ if (active) {
1285
+ return active;
1286
+ }
1287
+ }
1288
+ const threadId = readString(params, "threadId") ?? readNestedString(params, ["thread", "id"]);
1289
+ return threadId ? byThreadId.get(threadId) : undefined;
1290
+ }
1291
+
1292
+ type AppServerNotificationContext = {
1293
+ runId: string;
1294
+ providerThreadId: string;
1295
+ providerTurnId?: string;
1296
+ };
1297
+
1298
+ type AppServerNotificationEffect =
1299
+ | { kind: "setTurnId"; turnId: string }
1300
+ | { kind: "usage"; usage: AppServerUsage | undefined }
1301
+ | { kind: "teamEvent"; event: TeamRunEvent }
1302
+ | { kind: "finalResponse"; finalResponse: string }
1303
+ | { kind: "completeTurn"; turn: JsonObject | undefined };
1304
+
1305
+ function rawCodexEvent(message: JsonRpcMessage): RawCodexEvent {
1306
+ return { source: "codex.app-server", message };
1307
+ }
1308
+
1309
+ function interpretAppServerNotification(context: AppServerNotificationContext, message: JsonRpcMessage): AppServerNotificationEffect[] {
1310
+ const params = isRecord(message.params) ? message.params : {};
1311
+ switch (message.method) {
1312
+ case "turn/started": {
1313
+ const turn = readRecord(params, "turn");
1314
+ const turnId = readString(turn, "id") ?? readString(params, "turnId") ?? context.providerTurnId ?? "turn";
1315
+ return [
1316
+ ...(turnId ? [{ kind: "setTurnId" as const, turnId }] : []),
1317
+ {
1318
+ kind: "teamEvent",
1319
+ event: {
1320
+ type: "runner.turn.started",
1321
+ runId: context.runId,
1322
+ providerThreadId: context.providerThreadId,
1323
+ providerTurnId: turnId,
1324
+ turn,
1325
+ startedAtMs: readNumber(params, "startedAtMs")
1326
+ }
1327
+ },
1328
+ {
1329
+ kind: "teamEvent",
1330
+ event: {
1331
+ type: "runner.status.changed",
1332
+ runId: context.runId,
1333
+ phase: "working",
1334
+ headline: "Working",
1335
+ detail: turnId,
1336
+ providerThreadId: context.providerThreadId,
1337
+ providerTurnId: turnId
1338
+ }
1339
+ }
1340
+ ];
1341
+ }
1342
+ case "turn/completed": {
1343
+ const turn = readRecord(params, "turn");
1344
+ const turnId = readString(turn, "id") ?? readString(params, "turnId") ?? context.providerTurnId;
1345
+ return [
1346
+ {
1347
+ kind: "teamEvent",
1348
+ event: {
1349
+ type: "runner.turn.completed",
1350
+ runId: context.runId,
1351
+ providerThreadId: context.providerThreadId,
1352
+ providerTurnId: turnId,
1353
+ turn,
1354
+ completedAtMs: readNumber(params, "completedAtMs")
1355
+ }
1356
+ },
1357
+ { kind: "completeTurn", turn }
1358
+ ];
1359
+ }
1360
+ case "thread/tokenUsage/updated": {
1361
+ const usage = appServerUsageFromNotification(params);
1362
+ return [
1363
+ { kind: "usage", usage },
1364
+ {
1365
+ kind: "teamEvent",
1366
+ event: {
1367
+ type: "runner.status.changed",
1368
+ runId: context.runId,
1369
+ phase: "working",
1370
+ headline: "Working",
1371
+ detail: formatAppServerUsage(usage),
1372
+ providerThreadId: context.providerThreadId,
1373
+ providerTurnId: context.providerTurnId
1374
+ }
1375
+ }
1376
+ ];
1377
+ }
1378
+ case "item/started":
1379
+ case "item/completed":
1380
+ return mapAppServerThreadItemEvent(context, message.method, readRecord(params, "item"));
1381
+ case "item/agentMessage/delta":
1382
+ case "item/agentMessage/textDelta":
1383
+ case "item/agentMessage/outputDelta":
1384
+ case "item/assistantMessage/delta":
1385
+ case "item/assistantMessage/textDelta":
1386
+ case "item/assistantMessage/outputDelta": {
1387
+ const delta = readDeltaText(params);
1388
+ if (!delta) {
1389
+ return [];
1390
+ }
1391
+ const itemId = appServerItemId(params) ?? "agentMessage";
1392
+ const providerTurnId = readString(params, "turnId") ?? context.providerTurnId;
1393
+ return [{
1394
+ kind: "teamEvent",
1395
+ event: {
1396
+ type: "runner.item.delta",
1397
+ runId: context.runId,
1398
+ providerThreadId: context.providerThreadId,
1399
+ providerTurnId,
1400
+ itemId,
1401
+ deltaKind: "agentMessage",
1402
+ delta
1403
+ }
1404
+ }];
1405
+ }
1406
+ case "item/commandExecution/outputDelta":
1407
+ case "item/fileChange/outputDelta":
1408
+ case "item/plan/delta":
1409
+ case "item/reasoning/textDelta":
1410
+ case "item/reasoning/summaryTextDelta": {
1411
+ const delta = readDeltaText(params);
1412
+ if (!delta) {
1413
+ return [];
1414
+ }
1415
+ const deltaKind = appServerDeltaKind(message.method);
1416
+ const itemId = appServerItemId(params) ?? deltaKind;
1417
+ const providerTurnId = readString(params, "turnId") ?? context.providerTurnId;
1418
+ return [{
1419
+ kind: "teamEvent",
1420
+ event: {
1421
+ type: "runner.item.delta",
1422
+ runId: context.runId,
1423
+ providerThreadId: context.providerThreadId,
1424
+ providerTurnId,
1425
+ itemId,
1426
+ deltaKind,
1427
+ delta,
1428
+ contentIndex: readNumber(params, "contentIndex")
1429
+ }
1430
+ }];
1431
+ }
1432
+ case "turn/plan/updated": {
1433
+ const explanation = readString(params, "explanation");
1434
+ return [{
1435
+ kind: "teamEvent",
1436
+ event: {
1437
+ type: "runner.status.changed",
1438
+ runId: context.runId,
1439
+ phase: "thinking",
1440
+ headline: "Planning",
1441
+ detail: explanation ? truncateText(explanation, 500) : "Codex plan updated.",
1442
+ providerThreadId: context.providerThreadId,
1443
+ providerTurnId: context.providerTurnId
1444
+ }
1445
+ }];
1446
+ }
1447
+ case "error": {
1448
+ const error = readRecord(params, "error");
1449
+ return [{
1450
+ kind: "teamEvent",
1451
+ event: { type: "runner.error", runId: context.runId, error: readString(error, "message") ?? "Codex app-server error" }
1452
+ }];
1453
+ }
1454
+ default:
1455
+ return [];
1456
+ }
1457
+ }
1458
+
1459
+ function appServerUsageFromNotification(params: JsonObject): AppServerUsage | undefined {
1460
+ const usage = readRecord(params, "tokenUsage");
1461
+ const last = readRecord(usage, "last");
1462
+ if (!last) {
1463
+ return undefined;
1464
+ }
1465
+ return {
1466
+ input_tokens: readNumber(last, "inputTokens") ?? 0,
1467
+ cached_input_tokens: readNumber(last, "cachedInputTokens") ?? 0,
1468
+ output_tokens: readNumber(last, "outputTokens") ?? 0,
1469
+ reasoning_output_tokens: readNumber(last, "reasoningOutputTokens") ?? 0
1470
+ };
1471
+ }
1472
+
1473
+ function formatAppServerUsage(usage: AppServerUsage | undefined): string {
1474
+ if (!usage) {
1475
+ return "Codex app-server usage updated.";
1476
+ }
1477
+ return `Codex turn usage. Input ${usage.input_tokens}, output ${usage.output_tokens}, reasoning ${usage.reasoning_output_tokens}.`;
1478
+ }
1479
+
1480
+ function appServerApprovalSummary(method: string | undefined, params: JsonObject): string | undefined {
1481
+ switch (method) {
1482
+ case "item/commandExecution/requestApproval":
1483
+ return `Codex app-server requested command approval; Hunsu declined: ${readString(params, "command") ?? "unknown command"}`;
1484
+ case "item/fileChange/requestApproval":
1485
+ return `Codex app-server requested file-change approval; Hunsu declined${readString(params, "grantRoot") ? ` for ${readString(params, "grantRoot")}` : ""}.`;
1486
+ case "item/permissions/requestApproval":
1487
+ return "Codex app-server requested additional permissions; Hunsu declined.";
1488
+ case "execCommandApproval":
1489
+ return `Codex app-server requested command approval; Hunsu declined: ${(params.command as unknown[] | undefined)?.join(" ") ?? "unknown command"}`;
1490
+ case "applyPatchApproval":
1491
+ return "Codex app-server requested patch approval; Hunsu declined.";
1492
+ default:
1493
+ return undefined;
1494
+ }
1495
+ }
1496
+
1497
+ function mapAppServerThreadItemEvent(
1498
+ context: AppServerNotificationContext,
1499
+ method: "item/started" | "item/completed",
1500
+ item: JsonObject | undefined
1501
+ ): AppServerNotificationEffect[] {
1502
+ const phase = method === "item/started" ? "started" : "completed";
1503
+ const normalized = normalizeAppServerThreadItem(item);
1504
+ if (!normalized) {
1505
+ return [];
1506
+ }
1507
+ const itemId = normalized.id ?? appServerItemId(item) ?? normalized.type;
1508
+ const lifecycleEffect: AppServerNotificationEffect = {
1509
+ kind: "teamEvent",
1510
+ event: phase === "started"
1511
+ ? {
1512
+ type: "runner.item.started",
1513
+ runId: context.runId,
1514
+ providerThreadId: context.providerThreadId,
1515
+ providerTurnId: context.providerTurnId,
1516
+ itemId,
1517
+ item: normalized
1518
+ }
1519
+ : {
1520
+ type: "runner.item.completed",
1521
+ runId: context.runId,
1522
+ providerThreadId: context.providerThreadId,
1523
+ providerTurnId: context.providerTurnId,
1524
+ itemId,
1525
+ item: normalized
1526
+ }
1527
+ };
1528
+ switch (normalized.type) {
1529
+ case "agentMessage": {
1530
+ const text = normalized.text;
1531
+ if (phase !== "completed" || !text) {
1532
+ return [lifecycleEffect];
1533
+ }
1534
+ return [
1535
+ lifecycleEffect,
1536
+ { kind: "finalResponse", finalResponse: text }
1537
+ ];
1538
+ }
1539
+ case "reasoning": {
1540
+ return [lifecycleEffect];
1541
+ }
1542
+ case "plan": {
1543
+ return [lifecycleEffect];
1544
+ }
1545
+ case "commandExecution": {
1546
+ return [lifecycleEffect];
1547
+ }
1548
+ case "fileChange": {
1549
+ return [lifecycleEffect];
1550
+ }
1551
+ case "mcpToolCall": {
1552
+ return [lifecycleEffect];
1553
+ }
1554
+ case "webSearch": {
1555
+ return [lifecycleEffect];
1556
+ }
1557
+ default:
1558
+ return [lifecycleEffect];
1559
+ }
1560
+ }
1561
+
1562
+ function appServerDeltaKind(method: string | undefined): RunnerItemDeltaEvent["deltaKind"] {
1563
+ switch (method) {
1564
+ case "item/agentMessage/delta":
1565
+ case "item/agentMessage/textDelta":
1566
+ case "item/agentMessage/outputDelta":
1567
+ case "item/assistantMessage/delta":
1568
+ case "item/assistantMessage/textDelta":
1569
+ case "item/assistantMessage/outputDelta":
1570
+ return "agentMessage";
1571
+ case "item/plan/delta":
1572
+ return "plan";
1573
+ case "item/reasoning/textDelta":
1574
+ return "reasoningText";
1575
+ case "item/reasoning/summaryTextDelta":
1576
+ return "reasoningSummary";
1577
+ case "item/fileChange/outputDelta":
1578
+ return "fileChangeOutput";
1579
+ case "item/commandExecution/outputDelta":
1580
+ default:
1581
+ return "commandOutput";
1582
+ }
1583
+ }
1584
+
1585
+ function normalizeAppServerThreadItem(item: JsonObject | undefined): RunnerAppServerItem | undefined {
1586
+ const type = readString(item, "type");
1587
+ if (!item || !type) {
1588
+ return undefined;
1589
+ }
1590
+ const normalized: RunnerAppServerItem = {
1591
+ id: appServerItemId(item),
1592
+ type,
1593
+ raw: item
1594
+ };
1595
+ const text = readString(item, "text");
1596
+ if (text !== undefined) {
1597
+ normalized.text = text;
1598
+ } else {
1599
+ const contentText = readAppServerContentText(item);
1600
+ if (contentText !== undefined) {
1601
+ normalized.text = contentText;
1602
+ }
1603
+ }
1604
+ const summary = readAppServerTextParts(item.summary);
1605
+ if (summary.length > 0) {
1606
+ normalized.summary = summary;
1607
+ }
1608
+ const content = readAppServerTextParts(item.content);
1609
+ if (content.length > 0) {
1610
+ normalized.content = content;
1611
+ }
1612
+ const command = readString(item, "command");
1613
+ if (command !== undefined) {
1614
+ normalized.command = command;
1615
+ }
1616
+ const cwd = readString(item, "cwd");
1617
+ if (cwd !== undefined) {
1618
+ normalized.cwd = cwd;
1619
+ }
1620
+ const status = readString(item, "status");
1621
+ if (status !== undefined) {
1622
+ normalized.status = status;
1623
+ }
1624
+ const commandActions = readAppServerCommandActions(item);
1625
+ if (commandActions.length > 0) {
1626
+ normalized.commandActions = commandActions;
1627
+ }
1628
+ const aggregatedOutput = readString(item, "aggregatedOutput");
1629
+ if (aggregatedOutput !== undefined) {
1630
+ normalized.aggregatedOutput = aggregatedOutput;
1631
+ }
1632
+ const exitCode = readNumber(item, "exitCode");
1633
+ if (exitCode !== undefined) {
1634
+ normalized.exitCode = exitCode;
1635
+ }
1636
+ const durationMs = readNumber(item, "durationMs");
1637
+ if (durationMs !== undefined) {
1638
+ normalized.durationMs = durationMs;
1639
+ }
1640
+ if ("changes" in item) {
1641
+ normalized.changes = item.changes;
1642
+ }
1643
+ const server = readString(item, "server");
1644
+ if (server !== undefined) {
1645
+ normalized.server = server;
1646
+ }
1647
+ const tool = readString(item, "tool");
1648
+ if (tool !== undefined) {
1649
+ normalized.tool = tool;
1650
+ }
1651
+ const query = readString(item, "query");
1652
+ if (query !== undefined) {
1653
+ normalized.query = query;
1654
+ }
1655
+ return normalized;
1656
+ }
1657
+
1658
+ function readAppServerContentText(item: JsonObject): string | undefined {
1659
+ const content = readAppServerTextParts(item.content);
1660
+ if (content.length === 0) {
1661
+ return undefined;
1662
+ }
1663
+ return content.join("\n\n");
1664
+ }
1665
+
1666
+ function readAppServerTextParts(value: unknown): string[] {
1667
+ if (!Array.isArray(value)) {
1668
+ return [];
1669
+ }
1670
+ return value.flatMap((part): string[] => {
1671
+ if (typeof part === "string") {
1672
+ return part ? [part] : [];
1673
+ }
1674
+ if (!isRecord(part)) {
1675
+ return [];
1676
+ }
1677
+ const text = readString(part, "text") ?? readString(part, "content") ?? readString(part, "summary");
1678
+ return text ? [text] : [];
1679
+ });
1680
+ }
1681
+
1682
+ function readAppServerCommandActions(item: JsonObject): RunnerAppServerCommandAction[] {
1683
+ const value = item.commandActions;
1684
+ if (!Array.isArray(value)) {
1685
+ return [];
1686
+ }
1687
+ return value.flatMap((action): RunnerAppServerCommandAction[] => {
1688
+ if (!isRecord(action)) {
1689
+ return [];
1690
+ }
1691
+ const type = readString(action, "type");
1692
+ switch (type) {
1693
+ case "read":
1694
+ return [{ type, command: readString(action, "command"), name: readString(action, "name"), path: readString(action, "path") }];
1695
+ case "listFiles":
1696
+ return [{ type, command: readString(action, "command"), path: readString(action, "path") }];
1697
+ case "search":
1698
+ return [{ type, command: readString(action, "command"), query: readString(action, "query"), path: readString(action, "path") }];
1699
+ case "unknown":
1700
+ return [{ type, command: readString(action, "command") }];
1701
+ default:
1702
+ return [];
1703
+ }
1704
+ });
1705
+ }
1706
+
1707
+ function finalResponseFromAppServerTurn(turn: JsonObject | undefined): string | undefined {
1708
+ const items = Array.isArray(turn?.items) ? turn.items : [];
1709
+ for (const item of [...items].reverse()) {
1710
+ if (isRecord(item) && item.type === "agentMessage" && typeof item.text === "string") {
1711
+ return item.text;
1712
+ }
1713
+ }
1714
+ return undefined;
1715
+ }
1716
+
1717
+ function appServerItemId(value: unknown): string | undefined {
1718
+ if (!isRecord(value)) {
1719
+ return undefined;
1720
+ }
1721
+ return readString(value, "itemId") ?? readString(value, "id");
1722
+ }
1723
+
1724
+ function readRecord(value: unknown, key: string): JsonObject | undefined {
1725
+ if (!isRecord(value)) {
1726
+ return undefined;
1727
+ }
1728
+ const child = value[key];
1729
+ return isRecord(child) ? child : undefined;
1730
+ }
1731
+
1732
+ function readString(value: unknown, key: string): string | undefined {
1733
+ if (!isRecord(value)) {
1734
+ return undefined;
1735
+ }
1736
+ const child = value[key];
1737
+ return typeof child === "string" ? child : undefined;
1738
+ }
1739
+
1740
+ function readDeltaText(value: unknown): string | undefined {
1741
+ const delta = readString(value, "delta");
1742
+ if (delta !== undefined) {
1743
+ return delta;
1744
+ }
1745
+ const deltaBase64 = readString(value, "deltaBase64");
1746
+ if (!deltaBase64) {
1747
+ return undefined;
1748
+ }
1749
+ try {
1750
+ return Buffer.from(deltaBase64, deltaBase64.includes("-") || deltaBase64.includes("_") ? "base64url" : "base64").toString("utf8");
1751
+ } catch {
1752
+ return undefined;
1753
+ }
1754
+ }
1755
+
1756
+ function readNumber(value: unknown, key: string): number | undefined {
1757
+ if (!isRecord(value)) {
1758
+ return undefined;
1759
+ }
1760
+ const child = value[key];
1761
+ return typeof child === "number" ? child : undefined;
1762
+ }
1763
+
1764
+ function readStringArray(value: unknown, key: string): string[] {
1765
+ if (!isRecord(value) || !Array.isArray(value[key])) {
1766
+ return [];
1767
+ }
1768
+ return value[key].filter((item): item is string => typeof item === "string");
1769
+ }
1770
+
1771
+ function readNestedString(value: unknown, keys: string[]): string | undefined {
1772
+ let current = value;
1773
+ for (const key of keys) {
1774
+ if (!isRecord(current)) {
1775
+ return undefined;
1776
+ }
1777
+ current = current[key];
1778
+ }
1779
+ return typeof current === "string" ? current : undefined;
1780
+ }
1781
+
1782
+ function isRecord(value: unknown): value is JsonObject {
1783
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1784
+ }
1785
+
1786
+ export function buildTeamPlanningPrompt(input: StartRunInput): string {
1787
+ const protocol = teamHarnessForInput(input);
1788
+ return [
1789
+ "<role>",
1790
+ " <purpose>",
1791
+ " Plan a closure-free executable ExecutionPlan.",
1792
+ " </purpose>",
1793
+ "",
1794
+ " <instructions>",
1795
+ indentBlock(teamInstructionFor(input, protocol), 4),
1796
+ " </instructions>",
1797
+ "</role>",
1798
+ "",
1799
+ "<goal>",
1800
+ indentBlock(formatTeamGoal(input), 2),
1801
+ "</goal>",
1802
+ "",
1803
+ "<member_profiles>",
1804
+ formatMemberProfiles(protocol.members),
1805
+ "</member_profiles>",
1806
+ "",
1807
+ "<rules>",
1808
+ " Return one structured ExecutionPlan object with root kind queue.",
1809
+ " Put each executable step in the queue items array as kind goal with stage needs_evaluation.",
1810
+ " Use each goal item to delegate to one assignee Executor and optionally one evaluator Executor.",
1811
+ " A goal assignee must name executorId and a standalone goal.",
1812
+ " A goal evaluator, when present, must name executorId and a prompt that can return pass or fail with type, summary, reason, feedback, nextGoal, and evidence.",
1813
+ ` Set remainingAttempts to ${normalizePositiveInteger(input.maxAttemptCount) || maxAttemptCountForPrompt(protocol)} for each goal unless a smaller bound is clearly enough.`,
1814
+ " Set requires to PrevMove unless this goal intentionally depends on earlier Path ids.",
1815
+ "</rules>"
1816
+ ].join("\n");
1817
+ }
1818
+
1819
+ function maxAttemptCountForPrompt(protocol: HarnessSnapshot): number {
1820
+ switch (protocol.kind) {
1821
+ case "team_execution_plan":
1822
+ return protocol.maxAttemptCount;
1823
+ case "role_squad":
1824
+ case "council_vote":
1825
+ case "court_debate":
1826
+ return protocol.maxRoundCount;
1827
+ }
1828
+ }
1829
+
1830
+ function normalizePositiveInteger(value: number | undefined): number | undefined {
1831
+ return Number.isInteger(value) && value && value > 0 ? value : undefined;
1832
+ }
1833
+
1834
+ export function buildMemberPathPrompt(input: MemberPathRunInput): string {
1835
+ const member = memberConfigFor(input, input.memberPath.executorId);
1836
+ const renderedMemberPrompt = renderPromptTemplate(member.promptTemplate, memberPromptTemplateContext(input));
1837
+ return [
1838
+ "<member>",
1839
+ renderedMemberPrompt.trim() || "No additional Member prompt.",
1840
+ "</member>",
1841
+ "",
1842
+ "<goal>",
1843
+ input.memberPath.goal.trim(),
1844
+ "</goal>",
1845
+ "",
1846
+ "<constraints>",
1847
+ "Do not read .hunsu files.",
1848
+ "Do not create commits.",
1849
+ "Use bounded commands for verification; do not run preview servers, dev servers, watchers, or other long-running processes in the foreground.",
1850
+ "If you start a preview server, dev server, watcher, or any other long-running command, stop it before your final response.",
1851
+ "Do not leave running command sessions, local listeners, or background processes open at the end of the turn.",
1852
+ "</constraints>"
1853
+ ].join("\n");
1854
+ }
1855
+
1856
+ export function buildMoveFinalizerPrompt(input: MoveFinalizerInput): string {
1857
+ const selectedDestination = selectedDestinationForPrompt(input);
1858
+ const pathOutputs = input.pathOutputs.map(output => [
1859
+ `- ${output.pathId} (${output.executorId})`,
1860
+ ` goal: ${output.goal}`,
1861
+ output.commit ? ` commit: ${output.commit}` : undefined,
1862
+ output.finalResponse ? ` output: ${truncateText(output.finalResponse, 2000)}` : undefined
1863
+ ].filter(Boolean).join("\n")).join("\n");
1864
+
1865
+ return [
1866
+ "You are the MOVE finalizer for a completed Hunsu Execute.",
1867
+ "Write the Git commit message for MOVE N+1 from the source MOVE to terminal Path diff.",
1868
+ "Do not edit files. Do not create Git commits. Return only the commit message content.",
1869
+ "",
1870
+ `MOVE id: ${input.moveId}`,
1871
+ `Source MOVE commit: ${input.sourceMoveCommit}`,
1872
+ `Terminal Path commit: ${input.terminalPathCommit}`,
1873
+ input.terminalMemberPathId ? `Terminal Path id: ${input.terminalMemberPathId}` : undefined,
1874
+ "",
1875
+ "Destination:",
1876
+ selectedDestination ? formatDestinationForPrompt(selectedDestination) : `- ${input.requestGoal}`,
1877
+ "",
1878
+ "Completion summary:",
1879
+ input.completionSummary || "Not provided.",
1880
+ "",
1881
+ "Path commits:",
1882
+ Object.entries(input.pathCommits).map(([pathId, commit]) => `- ${pathId}: ${commit}`).join("\n") || "- none",
1883
+ "",
1884
+ "Path outputs:",
1885
+ pathOutputs || "- none",
1886
+ "",
1887
+ "Diff stat:",
1888
+ input.diffStat?.trim() || "Not provided.",
1889
+ "",
1890
+ "Diff name-status:",
1891
+ input.diffNameStatus?.trim() || "Not provided.",
1892
+ "",
1893
+ "Diff patch:",
1894
+ input.diffPatch?.trim() || "Not provided.",
1895
+ "",
1896
+ "Commit message requirements:",
1897
+ "- First line: concise imperative subject for the MOVE result.",
1898
+ "- Body: summarize the product changes and relevant Path outputs.",
1899
+ "- Do not include Hunsu trailers; Studio appends system trailers."
1900
+ ].filter((line): line is string => line !== undefined).join("\n");
1901
+ }
1902
+
1903
+ export function buildHunsuDraftPrompt(input: HunsuDraftTurnInput): string {
1904
+ const managerInstructions = renderPromptTemplate(input.manager.promptTemplate, providerPromptTemplateContext(input)).trim();
1905
+ return [
1906
+ "<role>",
1907
+ "You are the Hunsu Draft agent for a local Hunsu Studio session.",
1908
+ "Help the user refine a proposed HUNSU intervention conversationally.",
1909
+ "You may edit decoded draft request files under .hunsu-request/ in the supplied Route worktree.",
1910
+ "Do not edit .hunsu/ encoded runtime files, .hunsu-prev/, product files, or create commits.",
1911
+ "Available request runtime files are .hunsu-request/destinations.json, .hunsu-request/harness.json, .hunsu-request/executors.json, .hunsu-request/resources.json, and .hunsu-request/artifact-actions.json.",
1912
+ "Edit only the files required by the user's explicit request.",
1913
+ "Use destinations.json for Destination/TODO changes and artifact-actions.json for Artifact Action changes.",
1914
+ "For a simple TODO/Destination addition, edit only .hunsu-request/destinations.json.",
1915
+ ".hunsu-request/executors.json is the source of truth for Member definitions.",
1916
+ ".hunsu-request/resources.json is the source of truth for Skill, Plugin, and Resource bindings.",
1917
+ ".hunsu-request/harness.json stores Harness policy only; it must not contain a members field.",
1918
+ "Do not edit .hunsu-request/harness.json unless the user explicitly asks to change root Team or Harness policy.",
1919
+ "Do not edit .hunsu-request/executors.json unless the user explicitly asks to change Member definitions or Member config.",
1920
+ "Do not edit .hunsu-request/resources.json unless the user explicitly asks to change Resource bindings or requirements.",
1921
+ "Do not create, copy, or update Resource bindings or assignment entries for newly added Destinations; Team-planner routing and binding are handled outside Destination edits.",
1922
+ "After editing .hunsu-request files, run the supplied Draft check command. It creates a DiffArtifact in Hunsu Local.",
1923
+ "If the DiffArtifact passes, include this exact marker in your final reply: ::hunsu-diff{draftSessionId=\"<draftSessionId>\" diffArtifactId=\"<diffArtifactId>\" status=\"pass\"}",
1924
+ "If the DiffArtifact fails, include the same marker with status=\"failed\" and explain the validation error briefly.",
1925
+ "Confirmation is handled by Hunsu Local from the DiffArtifact id.",
1926
+ "</role>",
1927
+ "",
1928
+ "<manager>",
1929
+ `id: ${input.manager.id}`,
1930
+ "instructions:",
1931
+ indentBlock(managerInstructions || "No Manager-specific instructions.", 2),
1932
+ "</manager>",
1933
+ "",
1934
+ "<draft_check_command>",
1935
+ indentBlock(input.draftCheckCommand, 2),
1936
+ "</draft_check_command>",
1937
+ "",
1938
+ "<source_snapshot>",
1939
+ indentBlock(formatHunsuSourceSnapshot(input.sourceSnapshot), 2),
1940
+ "</source_snapshot>",
1941
+ "",
1942
+ "<request_goal>",
1943
+ indentBlock(input.requestGoal, 2),
1944
+ "</request_goal>",
1945
+ "",
1946
+ "<conversation>",
1947
+ indentBlock(formatHunsuDraftConversation(input.messages), 2),
1948
+ "</conversation>",
1949
+ "",
1950
+ "<latest_user_message>",
1951
+ indentBlock(input.userMessage, 2),
1952
+ "</latest_user_message>",
1953
+ "",
1954
+ "<response_rules>",
1955
+ "Answer naturally and briefly.",
1956
+ "If you edited .hunsu-request files, summarize the changed files and the intended runtime change after running the Draft check command.",
1957
+ "If the requested change cannot be represented in the provided .hunsu-request runtime files, say which runtime file or schema is missing.",
1958
+ "If more detail is needed, ask a focused follow-up question.",
1959
+ "Do not output JSON unless the user explicitly asks for an example.",
1960
+ "</response_rules>"
1961
+ ].join("\n");
1962
+ }
1963
+
1964
+ function renderProviderGoal(input: StartRunInput, config: MemberConfig): string {
1965
+ const renderedPrompt = renderPromptTemplate(config.promptTemplate, providerPromptTemplateContext(input));
1966
+ if (isHunsuDraftInput(input)) {
1967
+ return truncateText([
1968
+ "Objective: discuss a HUNSU draft with the user.",
1969
+ `Source: ${input.sourceSnapshot.teamName ?? "Team"} ${input.sourceSnapshot.moveOrdinal !== undefined ? `M${String(input.sourceSnapshot.moveOrdinal).padStart(4, "0")}` : input.sourceSnapshot.sourceNodeId}`,
1970
+ isHunsuDraftTurnInput(input) ? `User request: ${input.userMessage}` : "Status: preparing the Draft chat session before the first user message.",
1971
+ renderedPrompt.trim() ? `Instructions:\n${renderedPrompt.trim()}` : undefined
1972
+ ].filter(Boolean).join("\n\n"), 4000);
1973
+ }
1974
+ if (isMemberPathRunInput(input)) {
1975
+ const parts = [
1976
+ `Objective: ${input.memberPath.goal}`,
1977
+ renderedPrompt.trim() ? `Member:\n${renderedPrompt.trim()}` : undefined
1978
+ ].filter(Boolean);
1979
+ return truncateText(parts.join("\n\n"), 4000);
1980
+ }
1981
+ const parts = [
1982
+ `Objective:\n${formatTeamGoal(input)}`,
1983
+ `Instructions:\n${renderedPrompt.trim() || DEFAULT_TEAM_INSTRUCTION}`
1984
+ ].filter(Boolean);
1985
+ return truncateText(parts.join("\n\n"), 4000);
1986
+ }
1987
+
1988
+ function teamInstructionFor(input: StartRunInput, protocol: HarnessSnapshot): string {
1989
+ const prompt = renderPromptTemplate(protocol.team.promptTemplate, teamPromptTemplateContext(input, protocol)).trim();
1990
+ if (!prompt) {
1991
+ return DEFAULT_TEAM_INSTRUCTION;
1992
+ }
1993
+ return prompt;
1994
+ }
1995
+
1996
+ function teamPromptTemplateContext(input: StartRunInput, protocol: HarnessSnapshot): Record<string, unknown> {
1997
+ const selectedDestinations = selectedDestinationsForPrompt(input);
1998
+ return {
1999
+ requestGoal: input.requestGoal,
2000
+ currentDestination: selectedDestinations[0] ?? selectedDestinationForPrompt(input),
2001
+ currentDestinations: selectedDestinations,
2002
+ selectedDestinations,
2003
+ activeDestinations: input.activeDestinations,
2004
+ constraints: futureConstraintsForPrompt(input),
2005
+ members: protocol.members.map(member => ({
2006
+ id: member.id,
2007
+ promptTemplate: member.promptTemplate.template,
2008
+ model: member.model,
2009
+ reasoningEffort: member.reasoningEffort,
2010
+ serviceTier: member.serviceTier,
2011
+ skills: member.skills.map(skill => skill.name),
2012
+ plugins: member.plugins.map(plugin => plugin.id)
2013
+ }))
2014
+ };
2015
+ }
2016
+
2017
+ function memberPromptTemplateContext(input: MemberPathRunInput): Record<string, unknown> {
2018
+ const selectedDestinations = selectedDestinationsForPrompt(input);
2019
+ return {
2020
+ requestGoal: input.requestGoal,
2021
+ pathGoal: input.memberPath.goal,
2022
+ memberPath: input.memberPath,
2023
+ dependencyOutputs: input.dependencyOutputs ?? [],
2024
+ worktreeStatus: input.worktreeStatus,
2025
+ worktreeDiff: input.worktreeDiff,
2026
+ attemptTranscript: input.attemptTranscript,
2027
+ evaluationFeedback: input.attemptTranscript,
2028
+ currentDestination: selectedDestinations[0] ?? selectedDestinationForPrompt(input),
2029
+ currentDestinations: selectedDestinations,
2030
+ selectedDestinations,
2031
+ activeDestinations: input.activeDestinations,
2032
+ constraints: futureConstraintsForPrompt(input),
2033
+ sourceMoveId: input.sourceMoveId,
2034
+ targetMoveOrdinal: input.targetMoveOrdinal
2035
+ };
2036
+ }
2037
+
2038
+ function providerPromptTemplateContext(input: StartRunInput): Record<string, unknown> {
2039
+ if (isMemberPathRunInput(input)) {
2040
+ return memberPromptTemplateContext(input);
2041
+ }
2042
+ if (isHunsuDraftInput(input)) {
2043
+ return {
2044
+ requestGoal: input.requestGoal,
2045
+ sourceSnapshot: input.sourceSnapshot,
2046
+ userMessage: isHunsuDraftTurnInput(input) ? input.userMessage : undefined,
2047
+ messages: isHunsuDraftTurnInput(input) ? input.messages : []
2048
+ };
2049
+ }
2050
+ return teamPromptTemplateContext(input, teamHarnessForInput(input));
2051
+ }
2052
+
2053
+ function formatTeamGoal(input: StartRunInput): string {
2054
+ const selectedDestination = selectedDestinationForPrompt(input);
2055
+ const constraints = futureConstraintsForPrompt(input)
2056
+ .map(item => `- ${item}`)
2057
+ .join("\n");
2058
+ return [
2059
+ selectedDestination ? formatDestinationGoalForPrompt(selectedDestination) : `- ${input.requestGoal}`,
2060
+ constraints ? `Relevant constraints:\n${constraints}` : undefined
2061
+ ].filter((line): line is string => line !== undefined).join("\n\n");
2062
+ }
2063
+
2064
+ function selectedDestinationForPrompt(input: Pick<StartRunInput, "selectedDestinationIds" | "activeDestinations">): Destination | undefined {
2065
+ const selectedId = input.selectedDestinationIds?.[0];
2066
+ if (selectedId) {
2067
+ return input.activeDestinations.find(destination => destination.id === selectedId);
2068
+ }
2069
+ return [...input.activeDestinations].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0];
2070
+ }
2071
+
2072
+ function selectedDestinationsForPrompt(input: Pick<StartRunInput, "selectedDestinationIds" | "activeDestinations">): Destination[] {
2073
+ const selectedIds = input.selectedDestinationIds ?? [];
2074
+ if (selectedIds.length === 0) {
2075
+ const selected = selectedDestinationForPrompt(input);
2076
+ return selected ? [selected] : [];
2077
+ }
2078
+ const selected = new Set(selectedIds);
2079
+ return input.activeDestinations.filter(destination => selected.has(destination.id));
2080
+ }
2081
+
2082
+ function futureConstraintsForPrompt(input: Pick<StartRunInput, "futureConstraints" | "board">): string[] {
2083
+ return input.futureConstraints && input.futureConstraints.length > 0
2084
+ ? input.futureConstraints
2085
+ : input.board.futureConstraints.map(constraint => constraint.constraint);
2086
+ }
2087
+
2088
+ function formatDestinationGoalForPrompt(destination: Destination): string {
2089
+ const parts = [`- ${destination.title}`];
2090
+ if (destination.acceptanceCriteria && destination.acceptanceCriteria.length > 0) {
2091
+ parts.push(` Acceptance: ${destination.acceptanceCriteria.join("; ")}`);
2092
+ }
2093
+ if (destination.constraints && destination.constraints.length > 0) {
2094
+ parts.push(` Constraints: ${destination.constraints.join("; ")}`);
2095
+ }
2096
+ if (destination.notes?.trim()) {
2097
+ parts.push(` Notes: ${destination.notes.trim()}`);
2098
+ }
2099
+ return parts.join("\n");
2100
+ }
2101
+
2102
+ function formatMemberProfiles(members: MemberConfig[]): string {
2103
+ return members.map(member => [
2104
+ ` <member id="${xmlAttribute(member.id)}">`,
2105
+ " <profile>",
2106
+ indentBlock(member.promptTemplate.template.trim() || "No profile.", 6),
2107
+ " </profile>",
2108
+ " </member>"
2109
+ ].join("\n")).join("\n\n") || " <member id=\"default\">\n <profile>\n No profile.\n </profile>\n </member>";
2110
+ }
2111
+
2112
+ function indentBlock(text: string, spaces: number): string {
2113
+ const prefix = " ".repeat(spaces);
2114
+ return text.split("\n").map(line => line ? `${prefix}${line}` : "").join("\n");
2115
+ }
2116
+
2117
+ function xmlAttribute(value: string): string {
2118
+ return value
2119
+ .replaceAll("&", "&amp;")
2120
+ .replaceAll("\"", "&quot;")
2121
+ .replaceAll("<", "&lt;")
2122
+ .replaceAll(">", "&gt;");
2123
+ }
2124
+
2125
+ function formatDestinationForPrompt(destination: Destination): string {
2126
+ const parts = [`- ${destination.title}`];
2127
+ if (destination.acceptanceCriteria && destination.acceptanceCriteria.length > 0) {
2128
+ parts.push(` Acceptance: ${destination.acceptanceCriteria.join("; ")}`);
2129
+ }
2130
+ if (destination.constraints && destination.constraints.length > 0) {
2131
+ parts.push(` Constraints: ${destination.constraints.join("; ")}`);
2132
+ }
2133
+ if (destination.notes?.trim()) {
2134
+ parts.push(` Notes: ${destination.notes.trim()}`);
2135
+ }
2136
+ return parts.join("\n");
2137
+ }
2138
+
2139
+ function formatHunsuSourceSnapshot(snapshot: HunsuDraftSourceSnapshot): string {
2140
+ return [
2141
+ `draftSessionId source line: ${snapshot.sourceLineId}`,
2142
+ `source node: ${snapshot.sourceNodeId}`,
2143
+ snapshot.sourceMoveId ? `source move: ${snapshot.sourceMoveId}` : undefined,
2144
+ snapshot.moveOrdinal !== undefined ? `position: M${String(snapshot.moveOrdinal).padStart(4, "0")}` : undefined,
2145
+ snapshot.teamName ? `team: ${snapshot.teamName}` : undefined,
2146
+ snapshot.summary ? `summary: ${snapshot.summary}` : undefined,
2147
+ "destinations:",
2148
+ snapshot.destinationSummaries.length > 0
2149
+ ? snapshot.destinationSummaries.map(destination => `- ${destination.id} [${destination.status}] ${destination.title}`).join("\n")
2150
+ : "- none"
2151
+ ].filter((line): line is string => line !== undefined).join("\n");
2152
+ }
2153
+
2154
+ function formatHunsuDraftConversation(messages: HunsuDraftConversationMessage[]): string {
2155
+ if (messages.length === 0) {
2156
+ return "No prior draft conversation.";
2157
+ }
2158
+ return messages.map(message => {
2159
+ const label = message.role === "draft-agent" ? "Draft Agent" : message.role === "user" ? "User" : "System";
2160
+ return `${label}: ${truncateText(message.text, 1200)}`;
2161
+ }).join("\n\n");
2162
+ }
2163
+
2164
+ function teamPlanningConfigFor(input: StartRunInput): MemberConfig {
2165
+ const protocol = teamHarnessForInput(input);
2166
+ return createDefaultMemberConfig("team", teamInstructionFor(input, protocol), []);
2167
+ }
2168
+
2169
+ function moveFinalizerConfigFor(): MemberConfig {
2170
+ return createDefaultMemberConfig("move-finalizer", "Write a precise final MOVE commit message from the completed Execute diff and evidence.", []);
2171
+ }
2172
+
2173
+ function hunsuDraftConfigFor(manager: ManagerConfig = createDefaultManagerConfig()): MemberConfig {
2174
+ const config = createDefaultMemberConfig(String(manager.id), manager.promptTemplate.template, manager.skills);
2175
+ return {
2176
+ ...config,
2177
+ promptTemplate: { ...manager.promptTemplate },
2178
+ plugins: manager.plugins.map(plugin => ({ ...plugin })),
2179
+ execution: { kind: "worktree_write", network: "enabled" },
2180
+ approval: { policy: "never" }
2181
+ };
2182
+ }
2183
+
2184
+ function memberConfigFor(input: StartRunInput, executorId: string): MemberConfig {
2185
+ if (input.harnessGraph) {
2186
+ const member = getHarnessMember(input.harnessGraph, executorId);
2187
+ if (!member) {
2188
+ throw new Error(`Harness does not define required Member ${executorId}`);
2189
+ }
2190
+ return memberConfigFromMemberEntity(member);
2191
+ }
2192
+ const protocol = teamHarnessForInput(input);
2193
+ const member = getHarnessMemberConfig(protocol, executorId);
2194
+ if (!member) {
2195
+ throw new Error(`Harness ${protocol.kind} does not define required Member ${executorId}`);
2196
+ }
2197
+ return member;
2198
+ }
2199
+
2200
+ function teamHarnessForInput(input: StartRunInput): HarnessSnapshot {
2201
+ if (input.harnessGraph) {
2202
+ return harnessSnapshotForTeam(input.harnessGraph, input.teamScopeId ?? input.harnessGraph.rootTeamId);
2203
+ }
2204
+ return input.harness ?? createDefaultHarness();
2205
+ }
2206
+
2207
+ function threadOptionsForMember(member: MemberConfig): CodexRunnerThreadOptions {
2208
+ const options: CodexRunnerThreadOptions = {
2209
+ approvalPolicy: member.approval.policy === "on_request" ? "on-request" : "never",
2210
+ approvalsReviewer: member.approval.policy === "on_request" ? member.approval.reviewer : "user",
2211
+ networkAccessEnabled: member.execution.network === "enabled"
2212
+ };
2213
+ switch (member.execution.kind) {
2214
+ case "read_only":
2215
+ options.sandboxMode = "read-only";
2216
+ break;
2217
+ case "worktree_write":
2218
+ options.sandboxMode = "workspace-write";
2219
+ break;
2220
+ case "unrestricted":
2221
+ options.sandboxMode = "danger-full-access";
2222
+ break;
2223
+ }
2224
+ if (member.model && member.model !== "codex-default") {
2225
+ options.model = member.model;
2226
+ }
2227
+ if (member.reasoningEffort && member.reasoningEffort !== "default") {
2228
+ options.modelReasoningEffort = member.reasoningEffort;
2229
+ }
2230
+ return options;
2231
+ }
2232
+
2233
+ function isMemberPathRunInput(input: StartRunInput): input is MemberPathRunInput {
2234
+ return "memberPath" in input;
2235
+ }
2236
+
2237
+ function isHunsuDraftTurnInput(input: StartRunInput): input is HunsuDraftTurnInput {
2238
+ return "draftSessionId" in input && "sourceSnapshot" in input && "userMessage" in input;
2239
+ }
2240
+
2241
+ function isHunsuDraftInput(input: StartRunInput): input is HunsuDraftSessionInput | HunsuDraftTurnInput {
2242
+ return "draftSessionId" in input && "sourceSnapshot" in input;
2243
+ }
2244
+
2245
+ function createCodexEnvironment(env: ProcessEnvironment): Record<string, string> {
2246
+ const next: Record<string, string> = {};
2247
+ for (const [key, value] of Object.entries(env)) {
2248
+ if (value !== undefined) {
2249
+ next[key] = value;
2250
+ }
2251
+ }
2252
+ const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
2253
+ const workspaceBin = join(workspaceRoot, "node_modules", ".bin");
2254
+ next.PATH = [workspaceBin, env.PATH].filter(Boolean).join(delimiter);
2255
+ return next;
2256
+ }
2257
+
2258
+ function truncateText(text: string, maxLength = 4000): string {
2259
+ if (text.length <= maxLength) {
2260
+ return text;
2261
+ }
2262
+ return `${text.slice(0, maxLength - 1)}...`;
2263
+ }