@ours.network/fleet 1.1.0-nightly.8 → 1.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.
Files changed (202) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +9 -2
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +579 -173
  13. package/dist/briefing.js +85 -28
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +130 -55
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +193 -55
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +59 -9
  27. package/dist/fleet-command-audit.js +426 -63
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +21 -5
  50. package/dist/owner-channel/channel.js +312 -138
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/owner-channel/state.d.ts +2 -0
  58. package/dist/owner-channel/state.js +8 -2
  59. package/dist/permissions.js +1 -1
  60. package/dist/preset-migration.d.ts +42 -0
  61. package/dist/preset-migration.js +450 -0
  62. package/dist/reports/artifact.d.ts +10 -0
  63. package/dist/reports/artifact.js +32 -0
  64. package/dist/reports/index.d.ts +5 -0
  65. package/dist/reports/index.js +5 -0
  66. package/dist/reports/render.d.ts +6 -0
  67. package/dist/reports/render.js +240 -0
  68. package/dist/reports/service.d.ts +42 -0
  69. package/dist/reports/service.js +57 -0
  70. package/dist/reports/tasks.d.ts +52 -0
  71. package/dist/reports/tasks.js +177 -0
  72. package/dist/reports/types.d.ts +315 -0
  73. package/dist/reports/types.js +6 -0
  74. package/dist/resolved-plan.js +13 -1
  75. package/dist/rooms-tasks/cli.d.ts +2 -0
  76. package/dist/rooms-tasks/cli.js +434 -279
  77. package/dist/rooms-tasks/close.d.ts +9 -1
  78. package/dist/rooms-tasks/close.js +23 -7
  79. package/dist/rooms-tasks/config.js +16 -25
  80. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  81. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  82. package/dist/rooms-tasks/deletion.d.ts +51 -0
  83. package/dist/rooms-tasks/deletion.js +217 -0
  84. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  85. package/dist/rooms-tasks/external-worker.js +38 -0
  86. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  87. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  88. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  89. package/dist/rooms-tasks/member-overrides.js +204 -0
  90. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  91. package/dist/rooms-tasks/member-startup.js +9 -2
  92. package/dist/rooms-tasks/provision.js +146 -56
  93. package/dist/rooms-tasks/room-state.d.ts +1 -0
  94. package/dist/rooms-tasks/room-state.js +12 -1
  95. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  96. package/dist/rooms-tasks/task-room-name.js +61 -0
  97. package/dist/rooms-tasks/task-state.d.ts +115 -3
  98. package/dist/rooms-tasks/task-state.js +361 -11
  99. package/dist/rooms-tasks/templates.d.ts +4 -1
  100. package/dist/rooms-tasks/templates.js +34 -3
  101. package/dist/rooms-tasks/terminal.d.ts +3 -0
  102. package/dist/rooms-tasks/terminal.js +10 -4
  103. package/dist/rooms-tasks/types.d.ts +92 -7
  104. package/dist/rooms-tasks/types.js +14 -1
  105. package/dist/runner.d.ts +3 -0
  106. package/dist/runner.js +92 -33
  107. package/dist/session/acp.d.ts +4 -1
  108. package/dist/session/acp.js +29 -13
  109. package/dist/session/arbiter.d.ts +2 -1
  110. package/dist/session/arbiter.js +2 -0
  111. package/dist/session/codex-app-server-transport.d.ts +53 -0
  112. package/dist/session/codex-app-server-transport.js +184 -0
  113. package/dist/session/codex-app-server.d.ts +122 -0
  114. package/dist/session/codex-app-server.js +1031 -0
  115. package/dist/session/control.d.ts +2 -2
  116. package/dist/session/control.js +18 -2
  117. package/dist/session/conversation-types.d.ts +4 -4
  118. package/dist/session/types.d.ts +24 -6
  119. package/dist/session/types.js +28 -0
  120. package/dist/spawn.d.ts +9 -0
  121. package/dist/spawn.js +77 -9
  122. package/dist/web/fleet-config-service.d.ts +1 -0
  123. package/dist/web/fleet-config-service.js +25 -7
  124. package/dist/web/runtime.js +11 -6
  125. package/dist/web/server.js +28 -1
  126. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  127. package/dist/web-app/index.html +1 -1
  128. package/package.json +4 -4
  129. package/presets/brain-catalog.json +17 -0
  130. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  131. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  132. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  133. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  134. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  137. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  138. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  142. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  143. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  147. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  148. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  188. package/presets/fleet/roles/Coordinator.yaml +37 -0
  189. package/presets/fleet/roles/Critic.yaml +23 -1
  190. package/presets/fleet/roles/Developer.yaml +27 -5
  191. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  192. package/presets/fleet/room_templates/pair.yaml +9 -7
  193. package/presets/fleet/room_templates/single.yaml +5 -4
  194. package/presets/fleet/room_templates/team.yaml +12 -8
  195. package/presets/manifest.json +2 -1
  196. package/presets/fleet/agents/Architect.yaml +0 -3
  197. package/presets/fleet/agents/Secretary.yaml +0 -3
  198. package/presets/fleet/agents/Tester.yaml +0 -3
  199. package/presets/fleet/roles/Agent.yaml +0 -6
  200. package/presets/fleet/roles/Architect.yaml +0 -6
  201. package/presets/fleet/roles/Secretary.yaml +0 -6
  202. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -0,0 +1,1031 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { ConversationEventStore } from './conversation-store.js';
5
+ import { SessionEvents } from './events.js';
6
+ import { CodexAppServerTransport } from './codex-app-server-transport.js';
7
+ import { CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED, SessionControlError, sessionBackendCapabilities, turnResult, } from './types.js';
8
+ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
9
+ const CONTROLLER_GRACE_MS = 12_000;
10
+ const INTERRUPT_SETTLE_MS = 15_000;
11
+ const INTERRUPT_TERMINATE_GRACE_MS = 5_000;
12
+ const TERMINAL_RECONCILE_QUIET_MS = 2_000;
13
+ const TERMINAL_RECONCILE_REQUEST_TIMEOUT_MS = 5_000;
14
+ const SCHEDULED_REDACTION = '[scheduled-loop content redacted]';
15
+ const COMMENTARY_REDACTION = '[assistant commentary redacted]';
16
+ const OWNER_ADMIN_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_admin_console';
17
+ const OWNER_CHANNEL_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_channel';
18
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ const string = (value) => typeof value === 'string' ? value : undefined;
20
+ function sourceFor(origin) {
21
+ switch (origin?.kind) {
22
+ case 'owner-admin-console': return { source: 'owner_admin_console', persistBody: true };
23
+ case 'startup': return { source: 'startup', persistBody: true };
24
+ case 'owner': return { source: 'owner_channel', persistBody: false };
25
+ case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
26
+ case 'scheduled-loop': return { source: 'scheduled_loop', persistBody: false };
27
+ case 'local-console':
28
+ default: return { source: 'local_console', persistBody: true };
29
+ }
30
+ }
31
+ function textBlock(text, replacement) {
32
+ const original = text;
33
+ const bytes = Buffer.byteLength(original);
34
+ if (replacement !== undefined)
35
+ return {
36
+ type: 'text', text: replacement, bytes, redacted: true,
37
+ digest: createHash('sha256').update(original).digest('hex').slice(0, 24),
38
+ };
39
+ const max = 256 * 1024;
40
+ if (bytes <= max)
41
+ return { type: 'text', text, bytes };
42
+ const retained = Buffer.from(text).subarray(0, max).toString('utf8').replace(/�+$/u, '');
43
+ return {
44
+ type: 'text', text: retained, bytes, truncated: true,
45
+ digest: createHash('sha256').update(original).digest('hex').slice(0, 24),
46
+ };
47
+ }
48
+ function outcomeFor(status) {
49
+ if (status === 'completed')
50
+ return 'completed';
51
+ if (status === 'interrupted')
52
+ return 'cancelled';
53
+ if (status === 'failed')
54
+ return 'failed';
55
+ return 'inconclusive';
56
+ }
57
+ /** Server-generated application provenance for authenticated browser prompts only. */
58
+ export function nativePromptAdditionalContext(origin) {
59
+ if (origin?.kind === 'owner-admin-console')
60
+ return {
61
+ [OWNER_ADMIN_CONTEXT_KEY]: {
62
+ kind: 'application',
63
+ value: 'Direct owner admin console: server-authenticated paired console provenance; '
64
+ + 'the accompanying user text is direct owner input.',
65
+ },
66
+ };
67
+ if (origin?.kind === 'owner')
68
+ return {
69
+ [OWNER_CHANNEL_CONTEXT_KEY]: {
70
+ kind: 'application',
71
+ value: 'Authenticated Fleet owner channel: the supervisor authenticated the sender CID; '
72
+ + 'the accompanying user text is a direct owner instruction.',
73
+ },
74
+ };
75
+ return undefined;
76
+ }
77
+ function toolItem(item) {
78
+ switch (item.type) {
79
+ case 'commandExecution': return {
80
+ title: string(item.command) ?? 'Command execution', status: string(item.status),
81
+ };
82
+ case 'fileChange': return { title: 'File changes', status: string(item.status) };
83
+ case 'mcpToolCall': return {
84
+ title: `${string(item.server) ?? 'MCP'} / ${string(item.tool) ?? 'tool'}`,
85
+ status: string(item.status),
86
+ };
87
+ case 'dynamicToolCall': return {
88
+ title: string(item.tool) ?? 'Dynamic tool', status: string(item.status),
89
+ };
90
+ case 'collabAgentToolCall': return {
91
+ title: `Agent ${string(item.tool) ?? 'collaboration'}`, status: string(item.status),
92
+ };
93
+ case 'webSearch': return { title: 'Web search', status: string(item.status) };
94
+ case 'imageView': return { title: 'View image', status: string(item.status) };
95
+ case 'imageGeneration': return { title: 'Generate image', status: string(item.status) };
96
+ default: return undefined;
97
+ }
98
+ }
99
+ /** Native Codex session implementing Fleet's provider-neutral live-session contract. */
100
+ export class CodexAppServerSession {
101
+ options;
102
+ backend = 'codex-app-server';
103
+ capabilities = sessionBackendCapabilities('codex-app-server');
104
+ pid;
105
+ events;
106
+ conversation;
107
+ conversationStartCursor;
108
+ sessionGeneration = randomUUID();
109
+ threadFile;
110
+ pendingPermissions = new Map();
111
+ activeTools = new Set();
112
+ activeItems = new Set();
113
+ itemPhases = new Map();
114
+ itemText = new Map();
115
+ transport;
116
+ threadId;
117
+ readiness = 'starting';
118
+ runtimeModel;
119
+ reasoningEffort;
120
+ lastUpdateAt;
121
+ lastError;
122
+ exit = null;
123
+ queueDepth = 0;
124
+ promptTail = Promise.resolve();
125
+ activeTurn;
126
+ controllerCount = 0;
127
+ controllerGrace;
128
+ interruptForceKill;
129
+ terminalReconcile;
130
+ terminalReconcileInFlight = false;
131
+ finalAnswerObserved = false;
132
+ closing = false;
133
+ constructor(options, transport) {
134
+ this.options = options;
135
+ this.transport = transport;
136
+ this.pid = transport.pid;
137
+ this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
138
+ this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
139
+ roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
140
+ });
141
+ this.conversationStartCursor = this.conversation.lastCursor();
142
+ // Use Fleet's provider-neutral resume marker so runner recovery rotates the
143
+ // exact native thread id after a poisoned fast resume.
144
+ this.threadFile = join(options.stateDir, '.session-id');
145
+ }
146
+ static async start(options, transportFactory = CodexAppServerTransport.start) {
147
+ let instance;
148
+ const env = { ...options.env };
149
+ delete env.OURS_AUTOSTART;
150
+ const transport = await transportFactory({
151
+ name: options.name, argv: options.argv, cwd: options.cwd, env, log: options.log,
152
+ onNotification: (method, params) => instance?.notification(method, params),
153
+ onRequest: (method, id, params) => instance?.serverRequest(method, id, params),
154
+ onExit: exit => instance?.transportExited(exit),
155
+ });
156
+ instance = new CodexAppServerSession(options, transport);
157
+ try {
158
+ await instance.initialize();
159
+ instance.recoverOpenPrompts();
160
+ return instance;
161
+ }
162
+ catch (error) {
163
+ instance.fail(error);
164
+ await instance.close();
165
+ throw error;
166
+ }
167
+ }
168
+ isAlive() { return !this.closing && this.transport.isAlive(); }
169
+ snapshot() {
170
+ return {
171
+ backend: this.backend,
172
+ alive: this.isAlive(),
173
+ readiness: this.readiness,
174
+ sessionId: this.threadId,
175
+ lastError: this.lastError,
176
+ pendingPermissionId: this.pendingPermissions.keys().next().value,
177
+ runtimeModel: this.runtimeModel,
178
+ reasoningEffort: this.reasoningEffort,
179
+ permissionMode: this.options.permissionMode,
180
+ activity: {
181
+ activeToolCalls: this.activeTools.size,
182
+ ...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
183
+ },
184
+ };
185
+ }
186
+ async queuePrompt(text, options = {}) {
187
+ if (this.closing || !this.threadId || !this.transport.isAlive())
188
+ throw new SessionControlError('offline', this.lastError ?? 'Codex app-server session is offline');
189
+ let delivery;
190
+ if (options.interrupt && this.activeTurn) {
191
+ const interrupted = await this.interrupt(options.interruptSource ?? 'local-console');
192
+ if (interrupted.state === 'forced')
193
+ throw new SessionControlError('control-unavailable', 'Codex app-server restart is in progress after the cancellation deadline', interrupted.reasonCode ?? CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED);
194
+ delivery = 'interrupted';
195
+ }
196
+ if (options.steer && this.activeTurn?.nativeTurnId) {
197
+ const promptId = randomUUID();
198
+ const completion = this.steer(text, this.activeTurn.nativeTurnId, options.origin);
199
+ return { promptId, queuedBehind: 0, completion, origin: options.origin, delivery: 'started' };
200
+ }
201
+ const promptId = randomUUID();
202
+ const queuedBehind = this.queueDepth;
203
+ this.admit(promptId, text, queuedBehind, options);
204
+ this.queueDepth++;
205
+ const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
206
+ this.promptTail = run.then(() => undefined, () => undefined);
207
+ const completion = run.then(result => {
208
+ this.queueDepth = Math.max(0, this.queueDepth - 1);
209
+ return result;
210
+ }, error => {
211
+ this.queueDepth = Math.max(0, this.queueDepth - 1);
212
+ return turnResult(false, 'failed', error instanceof Error ? error.message : String(error));
213
+ });
214
+ return {
215
+ promptId, queuedBehind, completion, origin: options.origin,
216
+ delivery: delivery ?? (queuedBehind ? 'queued' : 'started'),
217
+ };
218
+ }
219
+ async submitPrompt(text, options = {}) {
220
+ try {
221
+ return await (await this.queuePrompt(text, options)).completion;
222
+ }
223
+ catch (error) {
224
+ if (error instanceof SessionControlError)
225
+ return turnResult(false, 'failed', error.message);
226
+ throw error;
227
+ }
228
+ }
229
+ async interrupt(source = 'local-console') {
230
+ const active = this.activeTurn;
231
+ if (!active || !this.threadId || !active.nativeTurnId)
232
+ return { state: 'settled' };
233
+ active.cancellationSource ??= source;
234
+ this.events.emit('state', { turnId: active.promptId, status: 'running', origin: active.origin });
235
+ this.conversation.appendSafe({
236
+ kind: 'prompt.interrupt_requested', sessionGeneration: this.sessionGeneration,
237
+ promptId: active.promptId, turnId: active.promptId,
238
+ payload: { cancellationSource: source },
239
+ });
240
+ try {
241
+ await this.transport.request('turn/interrupt', {
242
+ threadId: this.threadId, turnId: active.nativeTurnId,
243
+ }, 2_000);
244
+ }
245
+ catch (error) {
246
+ // A terminal notification can win the race with the interrupt response.
247
+ if (this.activeTurn !== active)
248
+ return { state: 'settled' };
249
+ throw error;
250
+ }
251
+ let timer;
252
+ const settled = await Promise.race([
253
+ active.settled.then(() => true),
254
+ new Promise(resolveTimeout => {
255
+ timer = setTimeout(() => resolveTimeout(false), this.options.interruptSettleMs ?? INTERRUPT_SETTLE_MS);
256
+ timer.unref?.();
257
+ }),
258
+ ]);
259
+ if (timer)
260
+ clearTimeout(timer);
261
+ if (settled)
262
+ return { state: 'settled' };
263
+ this.lastError = CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED;
264
+ this.transport.child.kill('SIGTERM');
265
+ this.clearInterruptForceKill();
266
+ this.interruptForceKill = setTimeout(() => {
267
+ this.interruptForceKill = undefined;
268
+ if (this.activeTurn !== active || !this.transport.isAlive())
269
+ return;
270
+ this.options.log(`[${this.options.name}] ${CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED}: `
271
+ + 'app-server ignored SIGTERM; sending SIGKILL');
272
+ this.transport.child.kill('SIGKILL');
273
+ }, this.options.interruptTerminateGraceMs ?? INTERRUPT_TERMINATE_GRACE_MS);
274
+ this.interruptForceKill.unref?.();
275
+ return { state: 'forced', reasonCode: CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED };
276
+ }
277
+ respondPermission(permissionId, optionId) {
278
+ const pending = this.pendingPermissions.get(permissionId);
279
+ if (!pending || !pending.options.some(option => option.optionId === optionId))
280
+ return false;
281
+ this.pendingPermissions.delete(permissionId);
282
+ if (pending.expiry)
283
+ clearTimeout(pending.expiry);
284
+ const decision = optionId === 'allow_once' ? 'accept'
285
+ : optionId === 'allow_session' ? 'acceptForSession'
286
+ : optionId === 'cancel' ? 'cancel' : 'decline';
287
+ this.transport.respond(pending.requestId, this.permissionResponse(pending, decision));
288
+ this.resolvePermission(permissionId, pending, decision.startsWith('accept') ? 'allowed' : 'denied', 'manual', optionId);
289
+ return true;
290
+ }
291
+ respondPermissionV2(permissionId, optionId, sessionGeneration) {
292
+ if (sessionGeneration !== this.sessionGeneration)
293
+ return 'stale';
294
+ return this.respondPermission(permissionId, optionId) ? 'accepted' : 'stale';
295
+ }
296
+ eventsSince(seq) { return this.events.since(seq); }
297
+ subscribe(listener) { return this.events.subscribe(listener); }
298
+ setControllerAttached(attached) {
299
+ const before = this.controllerCount;
300
+ this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
301
+ if (attached) {
302
+ if (this.controllerGrace)
303
+ clearTimeout(this.controllerGrace);
304
+ this.controllerGrace = undefined;
305
+ }
306
+ else if (before > 0 && this.controllerCount === 0 && this.pendingPermissions.size) {
307
+ this.controllerGrace = setTimeout(() => {
308
+ this.controllerGrace = undefined;
309
+ if (this.controllerCount || this.options.permissions.unattended !== 'deny')
310
+ return;
311
+ for (const [id, pending] of [...this.pendingPermissions])
312
+ this.autoResolvePermission(id, pending, 'permissions.unattended=deny', 'the last attached controller disconnected and the grace period expired');
313
+ }, this.options.controllerGraceMs ?? CONTROLLER_GRACE_MS);
314
+ this.controllerGrace.unref?.();
315
+ }
316
+ }
317
+ exitResult() { return this.exit ?? this.transport.exitResult(); }
318
+ async close() {
319
+ if (this.closing)
320
+ return;
321
+ this.closing = true;
322
+ if (this.controllerGrace)
323
+ clearTimeout(this.controllerGrace);
324
+ this.clearInterruptForceKill();
325
+ this.cancelTerminalReconciliation();
326
+ for (const [id, pending] of [...this.pendingPermissions])
327
+ this.autoResolvePermission(id, pending, undefined, 'the session closed');
328
+ if (this.activeTurn)
329
+ this.finishTurn('failed', 'Codex app-server session closed');
330
+ this.readiness = 'failed';
331
+ this.conversation.appendSafe({
332
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
333
+ payload: { status: 'offline' },
334
+ });
335
+ this.conversation.close();
336
+ await this.transport.close();
337
+ }
338
+ conversationPage(request = {}) {
339
+ const floor = Number(this.conversationStartCursor ?? 0);
340
+ const requested = Number(request.after ?? 0);
341
+ let after = String(Math.max(Number.isSafeInteger(floor) ? floor : 0, Number.isSafeInteger(requested) ? requested : 0));
342
+ const limit = Math.min(Math.max(request.limit ?? 200, 1), 1_000);
343
+ let page = this.conversation.page({ after, limit });
344
+ let visible = page.events.filter(event => this.currentEvent(event));
345
+ while (!visible.length && page.hasMore && page.nextCursor && page.nextCursor !== after) {
346
+ after = page.nextCursor;
347
+ page = this.conversation.page({ after, limit });
348
+ visible = page.events.filter(event => this.currentEvent(event));
349
+ }
350
+ return { ...page, events: visible, snapshot: this.conversationSnapshot() };
351
+ }
352
+ conversationSnapshot() {
353
+ return {
354
+ sessionGeneration: this.sessionGeneration,
355
+ readiness: this.isAlive() ? this.readiness : 'offline',
356
+ queueDepth: this.queueDepth,
357
+ pendingPermissionIds: [...this.pendingPermissions.keys()],
358
+ ...(this.conversation.degraded ? { historyDegraded: true } : {}),
359
+ };
360
+ }
361
+ subscribeConversation(listener) {
362
+ return this.conversation.subscribe(event => { if (this.currentEvent(event))
363
+ listener(event); });
364
+ }
365
+ async submitPromptBrowser(command) {
366
+ const bodyDigest = ConversationEventStore.bodyDigest(command.text);
367
+ const existing = this.conversation.receiptFor(command.commandId, bodyDigest);
368
+ if (existing)
369
+ return existing;
370
+ const queued = await this.queuePrompt(command.text, {
371
+ origin: { kind: 'owner-admin-console', commandId: command.commandId },
372
+ actor: { browserSession: command.actorBrowserSession },
373
+ });
374
+ const receipt = {
375
+ commandId: command.commandId, promptId: queued.promptId,
376
+ state: queued.queuedBehind ? 'queued' : 'starting', queuedBehind: queued.queuedBehind,
377
+ acceptedAt: new Date().toISOString(), eventCursor: this.conversation.lastCursor() ?? '0',
378
+ };
379
+ this.conversation.recordReceipt(command.commandId, receipt, bodyDigest);
380
+ return receipt;
381
+ }
382
+ async initialize() {
383
+ await this.transport.request('initialize', {
384
+ clientInfo: { name: 'ours-fleet', title: 'ours-fleet', version: '1' },
385
+ capabilities: { experimentalApi: true, requestAttestation: false },
386
+ });
387
+ this.transport.notify('initialized');
388
+ const persisted = this.options.mode === 'resume' && existsSync(this.threadFile)
389
+ ? readFileSync(this.threadFile, 'utf8').trim() : '';
390
+ const common = {
391
+ cwd: this.options.cwd,
392
+ approvalPolicy: this.options.approvalPolicy,
393
+ sandbox: this.options.sandbox,
394
+ model: this.options.model ?? null,
395
+ config: this.options.config ?? {},
396
+ runtimeWorkspaceRoots: [this.options.cwd, ...(this.options.addDirs ?? []).map(path => isAbsolute(path) ? path : resolve(this.options.cwd, path))],
397
+ };
398
+ const response = persisted
399
+ ? await this.transport.request('thread/resume', {
400
+ threadId: persisted, ...common, excludeTurns: true,
401
+ })
402
+ : await this.transport.request('thread/start', {
403
+ ...common, ephemeral: false, historyMode: 'paginated', threadSource: 'ours-fleet',
404
+ });
405
+ const thread = isObject(response.thread) ? response.thread : undefined;
406
+ this.threadId = string(thread?.id);
407
+ if (!this.threadId)
408
+ throw new Error('Codex app-server did not return a thread id');
409
+ writeFileSync(this.threadFile, this.threadId + '\n', { mode: 0o600 });
410
+ const model = string(response.model) ?? this.options.model ?? undefined;
411
+ if (model)
412
+ this.runtimeModel = { value: model };
413
+ const effort = string(response.reasoningEffort) ?? this.options.effort;
414
+ if (effort)
415
+ this.reasoningEffort = { value: effort };
416
+ this.readiness = 'idle';
417
+ this.events.emit('state', { status: 'idle', text: `Codex thread ${this.threadId}` });
418
+ this.conversation.appendSafe({
419
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
420
+ payload: { status: 'idle' },
421
+ });
422
+ }
423
+ recoverOpenPrompts() {
424
+ for (const open of this.conversation.openPrompts()) {
425
+ if (open.sessionGeneration === this.sessionGeneration)
426
+ continue;
427
+ if (open.state === 'started' || open.text === undefined) {
428
+ this.conversation.appendSafe({
429
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
430
+ promptId: open.promptId, turnId: open.promptId,
431
+ payload: { outcome: open.state === 'started' ? 'unknown_after_restart' : 'failed',
432
+ ...(open.text === undefined ? { stopReason: 'prompt-body-not-retained' } : {}) },
433
+ });
434
+ continue;
435
+ }
436
+ this.queueDepth++;
437
+ const origin = open.commandId
438
+ ? { kind: 'owner-admin-console', commandId: open.commandId }
439
+ : undefined;
440
+ const run = this.promptTail.then(() => this.runPrompt(open.text, open.promptId, origin));
441
+ this.promptTail = run.then(() => undefined, () => undefined);
442
+ void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
443
+ }
444
+ }
445
+ admit(promptId, text, queuedBehind, options) {
446
+ const { source, persistBody } = sourceFor(options.origin);
447
+ const bytes = Buffer.byteLength(text);
448
+ const draft = {
449
+ kind: 'prompt.admitted', sessionGeneration: this.sessionGeneration,
450
+ promptId, turnId: promptId, source,
451
+ ...(options.origin?.kind === 'owner-admin-console'
452
+ ? { commandId: options.origin.commandId } : {}),
453
+ ...(options.actor ? { actor: options.actor } : {}),
454
+ payload: {
455
+ queuedBehind,
456
+ ...(options.origin?.kind === 'owner' && options.origin.displayText !== undefined
457
+ ? { displayText: textBlock(options.origin.displayText) } : {}),
458
+ ...(persistBody ? { text: textBlock(text) }
459
+ : { external: { digest: ConversationEventStore.bodyDigest(text), bytes } }),
460
+ },
461
+ };
462
+ if (source === 'owner_admin_console') {
463
+ try {
464
+ this.conversation.append(draft);
465
+ }
466
+ catch (error) {
467
+ throw new SessionControlError('backend', `conversation store cannot record the prompt: ${error.message}`);
468
+ }
469
+ }
470
+ else
471
+ this.conversation.appendSafe(draft);
472
+ }
473
+ async runPrompt(text, promptId, origin) {
474
+ if (!this.threadId || !this.transport.isAlive())
475
+ return turnResult(false, 'failed', this.lastError ?? 'Codex app-server session is offline');
476
+ let settle;
477
+ const settled = new Promise(resolveResult => { settle = resolveResult; });
478
+ const active = { promptId, origin, output: '', settled, settle };
479
+ this.activeTurn = active;
480
+ this.finalAnswerObserved = false;
481
+ this.activeItems.clear();
482
+ this.readiness = 'running';
483
+ this.events.emit('state', { turnId: promptId, status: 'running', origin });
484
+ this.conversation.appendSafe({
485
+ kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
486
+ promptId, turnId: promptId, source: sourceFor(origin).source, payload: {},
487
+ });
488
+ try {
489
+ const response = await this.transport.request('turn/start', {
490
+ threadId: this.threadId,
491
+ input: [{ type: 'text', text, text_elements: [] }],
492
+ approvalPolicy: this.options.approvalPolicy,
493
+ model: this.options.model ?? null,
494
+ effort: this.options.effort ?? null,
495
+ ...(nativePromptAdditionalContext(origin)
496
+ ? { additionalContext: nativePromptAdditionalContext(origin) } : {}),
497
+ });
498
+ const turn = isObject(response.turn) ? response.turn : undefined;
499
+ active.nativeTurnId ??= string(turn?.id);
500
+ if (!active.nativeTurnId)
501
+ throw new Error('Codex app-server did not return a turn id');
502
+ this.scheduleTerminalReconciliation();
503
+ return await settled;
504
+ }
505
+ catch (error) {
506
+ if (this.activeTurn === active)
507
+ this.finishTurn('failed', error instanceof Error ? error.message : String(error));
508
+ return await settled;
509
+ }
510
+ }
511
+ async steer(text, expectedTurnId, origin) {
512
+ if (!this.threadId)
513
+ return turnResult(false, 'failed', 'Codex thread is unavailable');
514
+ try {
515
+ await this.transport.request('turn/steer', {
516
+ threadId: this.threadId, expectedTurnId,
517
+ input: [{ type: 'text', text, text_elements: [] }],
518
+ ...(nativePromptAdditionalContext(origin)
519
+ ? { additionalContext: nativePromptAdditionalContext(origin) } : {}),
520
+ });
521
+ return turnResult(true, 'inconclusive', 'injected');
522
+ }
523
+ catch (error) {
524
+ return turnResult(false, 'failed', error instanceof Error ? error.message : String(error));
525
+ }
526
+ }
527
+ notification(method, params) {
528
+ if (string(params.threadId) && this.threadId && params.threadId !== this.threadId)
529
+ return;
530
+ this.lastUpdateAt = new Date().toISOString();
531
+ switch (method) {
532
+ case 'turn/started': {
533
+ const turn = isObject(params.turn) ? params.turn : undefined;
534
+ if (this.activeTurn)
535
+ this.activeTurn.nativeTurnId ??= string(turn?.id);
536
+ break;
537
+ }
538
+ case 'turn/completed': {
539
+ const turn = isObject(params.turn) ? params.turn : {};
540
+ const turnId = string(turn.id);
541
+ if (!this.activeTurn || (turnId && this.activeTurn.nativeTurnId
542
+ && turnId !== this.activeTurn.nativeTurnId))
543
+ break;
544
+ this.activeTurn.nativeTurnId ??= turnId;
545
+ const error = isObject(turn.error) ? string(turn.error.message) : undefined;
546
+ this.finishTurn(outcomeFor(turn.status), error ?? string(turn.status));
547
+ break;
548
+ }
549
+ case 'item/started':
550
+ if (this.isActiveNotification(params) && isObject(params.item))
551
+ this.itemStarted(params.item);
552
+ break;
553
+ case 'item/completed':
554
+ if (this.isActiveNotification(params) && isObject(params.item))
555
+ this.itemCompleted(params.item);
556
+ break;
557
+ case 'item/agentMessage/delta':
558
+ if (this.isActiveNotification(params))
559
+ this.messageDelta(params);
560
+ break;
561
+ case 'item/reasoning/summaryTextDelta':
562
+ case 'item/reasoning/textDelta':
563
+ if (this.isActiveNotification(params))
564
+ this.thoughtDelta(params);
565
+ break;
566
+ case 'turn/plan/updated':
567
+ if (this.isActiveNotification(params))
568
+ this.planUpdated(params);
569
+ break;
570
+ case 'serverRequest/resolved':
571
+ this.serverRequestResolved(params);
572
+ break;
573
+ case 'warning':
574
+ case 'configWarning': {
575
+ const message = string(params.message) ?? string(params.summary);
576
+ if (message)
577
+ this.options.log(`[${this.options.name}] codex app-server: ${message}`);
578
+ break;
579
+ }
580
+ case 'error': {
581
+ const error = isObject(params.error) ? string(params.error.message) : undefined;
582
+ if (error) {
583
+ this.lastError = error;
584
+ this.events.emit('error', { turnId: this.activeTurn?.promptId, text: error });
585
+ this.conversation.appendSafe({
586
+ kind: 'error', sessionGeneration: this.sessionGeneration,
587
+ promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
588
+ payload: { message: error },
589
+ });
590
+ }
591
+ break;
592
+ }
593
+ case 'thread/status/changed': {
594
+ const status = isObject(params.status) ? string(params.status.type) : undefined;
595
+ if (status === 'systemError') {
596
+ this.lastError = 'Codex thread entered systemError';
597
+ this.cancelTerminalReconciliation();
598
+ if (this.activeTurn)
599
+ this.finishTurn('failed', this.lastError, true);
600
+ else {
601
+ this.readiness = 'failed';
602
+ this.events.emit('state', { status: 'failed', text: this.lastError });
603
+ }
604
+ this.terminateFailedTransport(this.lastError);
605
+ break;
606
+ }
607
+ if (status === 'idle' && this.activeTurn) {
608
+ this.scheduleTerminalReconciliation();
609
+ }
610
+ break;
611
+ }
612
+ default:
613
+ break;
614
+ }
615
+ }
616
+ itemStarted(item) {
617
+ const id = string(item.id);
618
+ if (!id)
619
+ return;
620
+ this.activeItems.add(id);
621
+ if (item.type === 'agentMessage') {
622
+ const phase = item.phase === 'commentary' || item.phase === 'final_answer' ? item.phase : undefined;
623
+ if (phase)
624
+ this.itemPhases.set(id, phase);
625
+ if (phase !== 'final_answer') {
626
+ this.finalAnswerObserved = false;
627
+ this.cancelTerminalReconciliation();
628
+ }
629
+ return;
630
+ }
631
+ this.finalAnswerObserved = false;
632
+ this.cancelTerminalReconciliation();
633
+ const tool = toolItem(item);
634
+ if (!tool)
635
+ return;
636
+ this.activeTools.add(id);
637
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
638
+ this.events.emit('tool_call', {
639
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
640
+ toolCallId: scheduled ? 'scheduled-loop-tool' : id,
641
+ title: scheduled ? 'scheduled-loop tool' : tool.title,
642
+ status: tool.status ?? 'inProgress',
643
+ });
644
+ this.recordTool(item, true);
645
+ }
646
+ itemCompleted(item) {
647
+ const id = string(item.id);
648
+ if (!id)
649
+ return;
650
+ this.activeItems.delete(id);
651
+ if (item.type === 'agentMessage') {
652
+ const phase = item.phase === 'commentary' || item.phase === 'final_answer'
653
+ ? item.phase : this.itemPhases.get(id);
654
+ if (phase)
655
+ this.itemPhases.set(id, phase);
656
+ const complete = string(item.text) ?? '';
657
+ const emitted = this.itemText.get(id) ?? '';
658
+ if (complete.length > emitted.length && complete.startsWith(emitted))
659
+ this.emitMessage(id, complete.slice(emitted.length), phase);
660
+ if (phase === 'final_answer' && complete.length > 0) {
661
+ this.finalAnswerObserved = true;
662
+ this.scheduleTerminalReconciliation();
663
+ }
664
+ return;
665
+ }
666
+ const tool = toolItem(item);
667
+ if (!tool)
668
+ return;
669
+ this.activeTools.delete(id);
670
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
671
+ this.events.emit('tool_update', {
672
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
673
+ toolCallId: scheduled ? 'scheduled-loop-tool' : id,
674
+ title: scheduled ? 'scheduled-loop tool' : tool.title,
675
+ status: tool.status ?? 'completed',
676
+ });
677
+ this.recordTool(item, false);
678
+ this.scheduleTerminalReconciliation();
679
+ }
680
+ messageDelta(params) {
681
+ const id = string(params.itemId);
682
+ const delta = string(params.delta);
683
+ if (!id || delta === undefined)
684
+ return;
685
+ this.emitMessage(id, delta, this.itemPhases.get(id));
686
+ }
687
+ emitMessage(id, delta, phase) {
688
+ this.itemText.set(id, (this.itemText.get(id) ?? '') + delta);
689
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
690
+ if (this.activeTurn && phase !== 'commentary')
691
+ this.activeTurn.output += delta;
692
+ this.events.emit('agent_text', {
693
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
694
+ text: scheduled ? SCHEDULED_REDACTION : delta,
695
+ messageId: id, ...(phase ? { messagePhase: phase } : {}),
696
+ });
697
+ this.conversation.appendSafe({
698
+ kind: 'message.chunk', sessionGeneration: this.sessionGeneration,
699
+ promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
700
+ messageId: id, source: 'agent',
701
+ payload: {
702
+ role: 'assistant', content: textBlock(delta, scheduled ? SCHEDULED_REDACTION : phase === 'commentary' ? COMMENTARY_REDACTION : undefined),
703
+ },
704
+ });
705
+ }
706
+ thoughtDelta(params) {
707
+ const delta = string(params.delta);
708
+ if (delta === undefined)
709
+ return;
710
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
711
+ this.events.emit('thought', {
712
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
713
+ text: scheduled ? SCHEDULED_REDACTION : delta,
714
+ });
715
+ this.conversation.appendSafe({
716
+ kind: 'thought.chunk', sessionGeneration: this.sessionGeneration,
717
+ promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
718
+ messageId: string(params.itemId), source: 'agent',
719
+ payload: { content: textBlock(delta, scheduled ? SCHEDULED_REDACTION : undefined) },
720
+ });
721
+ }
722
+ planUpdated(params) {
723
+ const plan = Array.isArray(params.plan) ? params.plan : [];
724
+ this.conversation.appendSafe({
725
+ kind: 'plan.replace', sessionGeneration: this.sessionGeneration,
726
+ promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId, source: 'agent',
727
+ payload: { entries: plan.filter(isObject).map(entry => ({
728
+ content: textBlock(string(entry.step) ?? string(entry.content) ?? ''),
729
+ priority: 'medium',
730
+ status: entry.status === 'completed' ? 'completed'
731
+ : entry.status === 'inProgress' ? 'in_progress' : 'pending',
732
+ })) },
733
+ });
734
+ }
735
+ recordTool(item, snapshot) {
736
+ const id = string(item.id);
737
+ const tool = toolItem(item);
738
+ if (!id || !tool)
739
+ return;
740
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
741
+ this.conversation.appendSafe({
742
+ kind: 'tool.upsert', sessionGeneration: this.sessionGeneration,
743
+ promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
744
+ toolCallId: scheduled ? 'scheduled-loop-tool' : id, source: 'agent',
745
+ payload: {
746
+ toolCallId: scheduled ? 'scheduled-loop-tool' : id, snapshot,
747
+ title: scheduled ? 'scheduled-loop tool' : tool.title, status: tool.status,
748
+ kind: string(item.type),
749
+ },
750
+ });
751
+ }
752
+ finishTurn(outcome, detail, sessionFailed = false) {
753
+ const active = this.activeTurn;
754
+ if (!active)
755
+ return;
756
+ this.cancelTerminalReconciliation();
757
+ this.clearInterruptForceKill();
758
+ this.activeTurn = undefined;
759
+ this.readiness = !sessionFailed && this.transport.isAlive() && !this.closing ? 'idle' : 'failed';
760
+ this.activeTools.clear();
761
+ this.activeItems.clear();
762
+ this.itemPhases.clear();
763
+ this.itemText.clear();
764
+ this.finalAnswerObserved = false;
765
+ const result = turnResult(true, outcome, detail, active.output, active.cancellationSource);
766
+ this.events.emit('turn_stop', {
767
+ turnId: active.promptId, origin: active.origin, stopReason: detail ?? outcome,
768
+ cancellationSource: active.cancellationSource,
769
+ });
770
+ this.events.emit('state', { status: this.readiness });
771
+ this.conversation.appendSafe({
772
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
773
+ promptId: active.promptId, turnId: active.promptId,
774
+ payload: {
775
+ outcome, ...(detail ? { stopReason: detail } : {}),
776
+ ...(active.cancellationSource ? { cancellationSource: active.cancellationSource } : {}),
777
+ },
778
+ });
779
+ active.settle(result);
780
+ }
781
+ /**
782
+ * Some supported Codex versions can persist a completed turn without
783
+ * emitting `turn/completed`. Reconcile only an exact, quiescent final-answer
784
+ * candidate against the app-server's authoritative thread snapshot.
785
+ */
786
+ scheduleTerminalReconciliation() {
787
+ this.cancelTerminalReconciliation();
788
+ if (!this.terminalCandidate())
789
+ return;
790
+ this.terminalReconcile = setTimeout(() => {
791
+ this.terminalReconcile = undefined;
792
+ void this.reconcileTerminal();
793
+ }, this.options.terminalReconcileQuietMs ?? TERMINAL_RECONCILE_QUIET_MS);
794
+ this.terminalReconcile.unref?.();
795
+ }
796
+ terminalCandidate() {
797
+ return Boolean(this.activeTurn?.nativeTurnId && this.finalAnswerObserved
798
+ && this.activeItems.size === 0 && this.pendingPermissions.size === 0
799
+ && !this.terminalReconcileInFlight && !this.closing && this.transport.isAlive());
800
+ }
801
+ async reconcileTerminal() {
802
+ if (!this.terminalCandidate() || !this.threadId || !this.activeTurn?.nativeTurnId)
803
+ return;
804
+ const active = this.activeTurn;
805
+ const turnId = active.nativeTurnId;
806
+ this.terminalReconcileInFlight = true;
807
+ try {
808
+ const response = await this.transport.request('thread/read', {
809
+ threadId: this.threadId, includeTurns: true,
810
+ }, this.options.terminalReconcileRequestTimeoutMs
811
+ ?? TERMINAL_RECONCILE_REQUEST_TIMEOUT_MS);
812
+ if (this.activeTurn !== active || !this.finalAnswerObserved)
813
+ return;
814
+ const thread = isObject(response.thread) ? response.thread : undefined;
815
+ const turns = thread && Array.isArray(thread.turns) ? thread.turns : [];
816
+ const turn = turns.find(candidate => isObject(candidate) && candidate.id === turnId);
817
+ if (!isObject(turn))
818
+ return;
819
+ const status = string(turn.status);
820
+ const threadStatus = thread && isObject(thread.status) ? string(thread.status.type) : undefined;
821
+ const terminal = status === 'completed' || status === 'failed' || status === 'interrupted';
822
+ if (!terminal && threadStatus !== 'idle')
823
+ return;
824
+ const error = isObject(turn.error) ? string(turn.error.message) : undefined;
825
+ const outcome = terminal ? outcomeFor(status) : 'completed';
826
+ this.options.log(`[${this.options.name}] inferred missing turn/completed after `
827
+ + `authoritative reconciliation (${this.threadId}/${turnId})`);
828
+ this.finishTurn(outcome, error ?? (terminal ? status : 'inferred missing turn/completed'));
829
+ }
830
+ catch (error) {
831
+ if (this.activeTurn === active && this.transport.isAlive())
832
+ this.options.log(`[${this.options.name}] Codex terminal reconciliation failed: `
833
+ + `${error instanceof Error ? error.message : String(error)}`);
834
+ }
835
+ finally {
836
+ this.terminalReconcileInFlight = false;
837
+ if (this.activeTurn === active)
838
+ this.scheduleTerminalReconciliation();
839
+ }
840
+ }
841
+ cancelTerminalReconciliation() {
842
+ if (this.terminalReconcile)
843
+ clearTimeout(this.terminalReconcile);
844
+ this.terminalReconcile = undefined;
845
+ }
846
+ clearInterruptForceKill() {
847
+ if (this.interruptForceKill)
848
+ clearTimeout(this.interruptForceKill);
849
+ this.interruptForceKill = undefined;
850
+ }
851
+ terminateFailedTransport(reason) {
852
+ if (!this.transport.isAlive())
853
+ return;
854
+ this.transport.child.kill('SIGTERM');
855
+ if (!this.transport.isAlive())
856
+ return;
857
+ this.clearInterruptForceKill();
858
+ this.interruptForceKill = setTimeout(() => {
859
+ this.interruptForceKill = undefined;
860
+ if (!this.transport.isAlive())
861
+ return;
862
+ this.options.log(`[${this.options.name}] ${reason}; app-server ignored SIGTERM; sending SIGKILL`);
863
+ this.transport.child.kill('SIGKILL');
864
+ }, this.options.interruptTerminateGraceMs ?? INTERRUPT_TERMINATE_GRACE_MS);
865
+ this.interruptForceKill.unref?.();
866
+ }
867
+ serverRequest(method, id, params) {
868
+ if (string(params.threadId) && this.threadId && params.threadId !== this.threadId) {
869
+ this.transport.respondError(id, -32602, 'request belongs to another thread');
870
+ return;
871
+ }
872
+ if (method === 'item/commandExecution/requestApproval'
873
+ || method === 'item/fileChange/requestApproval'
874
+ || method === 'item/permissions/requestApproval') {
875
+ this.requestPermission(method, id, params);
876
+ return;
877
+ }
878
+ if (method === 'currentTime/read') {
879
+ this.transport.respond(id, { currentTimeAt: Math.floor(Date.now() / 1_000) });
880
+ return;
881
+ }
882
+ if (method === 'item/tool/requestUserInput') {
883
+ this.options.log(`[${this.options.name}] codex app-server: requestUserInput has no interactive Fleet mapping; returning no answers`);
884
+ this.transport.respond(id, { answers: {} });
885
+ return;
886
+ }
887
+ this.transport.respondError(id, -32601, `unsupported Codex server request: ${method}`);
888
+ }
889
+ requestPermission(method, requestId, params) {
890
+ const permissionId = randomUUID();
891
+ const toolCallId = string(params.itemId) ?? randomUUID();
892
+ const available = Array.isArray(params.availableDecisions)
893
+ ? params.availableDecisions.filter(value => typeof value === 'string') : [];
894
+ const options = [
895
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once', native: 'accept' },
896
+ { optionId: 'allow_session', name: 'Allow for session', kind: 'allow_always', native: 'acceptForSession' },
897
+ { optionId: 'deny_once', name: 'Deny', kind: 'reject_once', native: 'decline' },
898
+ { optionId: 'cancel', name: 'Cancel', kind: 'reject_once', native: 'cancel' },
899
+ ].filter(option => method === 'item/fileChange/requestApproval'
900
+ || method === 'item/permissions/requestApproval'
901
+ || !available.length || available.includes(option.native))
902
+ .map(({ native: _native, ...option }) => option);
903
+ const pending = {
904
+ requestId, method, toolCallId, options,
905
+ ...(method === 'item/permissions/requestApproval' && isObject(params.permissions)
906
+ ? { requestedPermissions: params.permissions } : {}),
907
+ };
908
+ // A generic permissions request is an explicit sandbox expansion. Portable
909
+ // `approval=allow` covers native operations inside the declared boundary;
910
+ // it must never silently widen filesystem or network authority.
911
+ if (this.options.permissions.approval === 'allow'
912
+ && method !== 'item/permissions/requestApproval') {
913
+ this.transport.respond(requestId, this.permissionResponse(pending, 'accept'));
914
+ this.resolvePermission(permissionId, pending, 'allowed', 'automatic', 'allow_once', 'permissions.approval=allow', 'the native request is inside the configured Codex boundary');
915
+ return;
916
+ }
917
+ const unattended = this.controllerCount === 0 && !this.controllerGrace
918
+ && this.options.permissions.unattended === 'deny';
919
+ if (this.options.permissions.approval === 'deny' || unattended) {
920
+ this.transport.respond(requestId, this.permissionResponse(pending, 'decline'));
921
+ this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended ? 'no controller is attached' : 'the role denies permission requests');
922
+ return;
923
+ }
924
+ this.pendingPermissions.set(permissionId, pending);
925
+ this.readiness = 'awaiting_permission';
926
+ const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
927
+ const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
928
+ const title = string(params.command) ?? string(params.reason)
929
+ ?? (method === 'item/fileChange/requestApproval' ? 'Apply file changes'
930
+ : method === 'item/permissions/requestApproval' ? 'Grant additional permissions'
931
+ : 'Run command');
932
+ this.events.emit('permission', {
933
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
934
+ permissionId, toolCallId, title, status: 'pending', options,
935
+ });
936
+ this.conversation.appendSafe({
937
+ kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
938
+ permissionId, promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
939
+ toolCallId, payload: { toolCallId, title, options, expiresAt },
940
+ });
941
+ pending.expiry = setTimeout(() => {
942
+ this.autoResolvePermission(permissionId, pending, undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
943
+ }, timeoutMs);
944
+ pending.expiry.unref?.();
945
+ }
946
+ serverRequestResolved(params) {
947
+ const requestId = typeof params.requestId === 'string' || typeof params.requestId === 'number'
948
+ ? params.requestId : undefined;
949
+ if (requestId === undefined)
950
+ return;
951
+ const entry = [...this.pendingPermissions].find(([, pending]) => pending.requestId === requestId);
952
+ if (!entry)
953
+ return;
954
+ const [permissionId, pending] = entry;
955
+ this.pendingPermissions.delete(permissionId);
956
+ if (pending.expiry)
957
+ clearTimeout(pending.expiry);
958
+ this.resolvePermission(permissionId, pending, 'cancelled', 'automatic', undefined, undefined, 'Codex resolved the native server request externally');
959
+ }
960
+ autoResolvePermission(permissionId, pending, policy, reason) {
961
+ if (this.pendingPermissions.get(permissionId) === pending)
962
+ this.pendingPermissions.delete(permissionId);
963
+ if (pending.expiry)
964
+ clearTimeout(pending.expiry);
965
+ if (this.transport.isAlive())
966
+ this.transport.respond(pending.requestId, this.permissionResponse(pending, 'decline'));
967
+ this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', policy, reason);
968
+ }
969
+ resolvePermission(permissionId, pending, decision, decisionSource, optionId, policy, reason) {
970
+ this.events.emit('permission', {
971
+ turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
972
+ permissionId, toolCallId: pending.toolCallId, status: 'completed', decision,
973
+ decisionSource, optionId, policy, reason,
974
+ });
975
+ this.conversation.appendSafe({
976
+ kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
977
+ permissionId, promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
978
+ toolCallId: pending.toolCallId,
979
+ payload: { decision, decisionSource, ...(optionId ? { optionId } : {}),
980
+ ...(policy ? { policy } : {}), ...(reason ? { reason } : {}) },
981
+ });
982
+ if (!this.pendingPermissions.size && this.readiness === 'awaiting_permission')
983
+ this.readiness = 'running';
984
+ this.scheduleTerminalReconciliation();
985
+ }
986
+ currentEvent(event) {
987
+ return event.sessionGeneration === this.sessionGeneration && event.source !== 'agent_replay';
988
+ }
989
+ isActiveNotification(params) {
990
+ if (!this.activeTurn)
991
+ return false;
992
+ const turnId = string(params.turnId);
993
+ return !turnId || !this.activeTurn.nativeTurnId || turnId === this.activeTurn.nativeTurnId;
994
+ }
995
+ permissionResponse(pending, decision) {
996
+ if (pending.method !== 'item/permissions/requestApproval')
997
+ return { decision };
998
+ const requested = pending.requestedPermissions ?? {};
999
+ const permissions = {};
1000
+ if (isObject(requested.network))
1001
+ permissions.network = requested.network;
1002
+ if (isObject(requested.fileSystem))
1003
+ permissions.fileSystem = requested.fileSystem;
1004
+ return {
1005
+ permissions: decision.startsWith('accept') ? permissions : {},
1006
+ scope: decision === 'acceptForSession' ? 'session' : 'turn',
1007
+ };
1008
+ }
1009
+ transportExited(exit) {
1010
+ this.clearInterruptForceKill();
1011
+ this.cancelTerminalReconciliation();
1012
+ this.exit = this.lastError === CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED
1013
+ ? { ...exit, detail: `${this.lastError}; ${exit.detail}` } : exit;
1014
+ if (!this.closing) {
1015
+ this.readiness = 'failed';
1016
+ this.lastError ??= `Codex app-server ${this.exit.detail}`;
1017
+ this.events.emit('state', { status: 'failed', text: this.lastError });
1018
+ this.conversation.appendSafe({
1019
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
1020
+ payload: { status: 'failed', detail: this.lastError },
1021
+ });
1022
+ if (this.activeTurn)
1023
+ this.finishTurn('failed', this.lastError);
1024
+ }
1025
+ }
1026
+ fail(error) {
1027
+ this.lastError = error instanceof Error ? error.message : String(error);
1028
+ this.readiness = 'failed';
1029
+ this.events.emit('error', { text: this.lastError });
1030
+ }
1031
+ }