@ai-devkit/agent-manager 0.28.1 → 0.30.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 (219) hide show
  1. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +1 -342
  2. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -1
  3. package/dist/__tests__/capacity/codex.test.js +343 -0
  4. package/dist/__tests__/capacity/codex.test.js.map +1 -0
  5. package/dist/__tests__/capacity/index.test.js +50 -0
  6. package/dist/__tests__/capacity/index.test.js.map +1 -0
  7. package/dist/__tests__/database/DurableAgentsDatabase.test.js +31 -3
  8. package/dist/__tests__/database/DurableAgentsDatabase.test.js.map +1 -1
  9. package/dist/__tests__/durable/CodexCliProbe.test.js +129 -0
  10. package/dist/__tests__/durable/CodexCliProbe.test.js.map +1 -0
  11. package/dist/__tests__/durable/CodexDurableAgentRepository.test.js +84 -0
  12. package/dist/__tests__/durable/CodexDurableAgentRepository.test.js.map +1 -0
  13. package/dist/__tests__/durable/CodexPrintAgent.integration.test.js +126 -0
  14. package/dist/__tests__/durable/CodexPrintAgent.integration.test.js.map +1 -0
  15. package/dist/__tests__/durable/CodexPrintAgentService.test.js +271 -0
  16. package/dist/__tests__/durable/CodexPrintAgentService.test.js.map +1 -0
  17. package/dist/__tests__/durable/CodexPrintRunner.test.js +393 -0
  18. package/dist/__tests__/durable/CodexPrintRunner.test.js.map +1 -0
  19. package/dist/__tests__/print/ClaudeCliProbe.test.js +3 -7
  20. package/dist/__tests__/print/ClaudeCliProbe.test.js.map +1 -1
  21. package/dist/__tests__/print/ClaudePrintAgent.integration.test.js +33 -27
  22. package/dist/__tests__/print/ClaudePrintAgent.integration.test.js.map +1 -1
  23. package/dist/__tests__/print/ClaudePrintAgentService.test.js +65 -0
  24. package/dist/__tests__/print/ClaudePrintAgentService.test.js.map +1 -1
  25. package/dist/__tests__/print/ClaudePrintRunner.test.js +4 -10
  26. package/dist/__tests__/print/ClaudePrintRunner.test.js.map +1 -1
  27. package/dist/__tests__/providers/claude/ClaudeAgentMapper.test.js +79 -0
  28. package/dist/__tests__/providers/claude/ClaudeAgentMapper.test.js.map +1 -0
  29. package/dist/__tests__/providers/claude/ClaudeSessionLocator.test.js +436 -0
  30. package/dist/__tests__/providers/claude/ClaudeSessionLocator.test.js.map +1 -0
  31. package/dist/__tests__/providers/pi/durable/PiCliProbe.test.js +76 -0
  32. package/dist/__tests__/providers/pi/durable/PiCliProbe.test.js.map +1 -0
  33. package/dist/__tests__/providers/pi/durable/PiDurableAgentRepository.test.js +43 -0
  34. package/dist/__tests__/providers/pi/durable/PiDurableAgentRepository.test.js.map +1 -0
  35. package/dist/__tests__/providers/pi/durable/PiPrintAgentService.test.js +202 -0
  36. package/dist/__tests__/providers/pi/durable/PiPrintAgentService.test.js.map +1 -0
  37. package/dist/__tests__/providers/pi/durable/PiPrintProtocol.test.js +119 -0
  38. package/dist/__tests__/providers/pi/durable/PiPrintProtocol.test.js.map +1 -0
  39. package/dist/__tests__/providers/pi/durable/PiPrintRunner.test.js +386 -0
  40. package/dist/__tests__/providers/pi/durable/PiPrintRunner.test.js.map +1 -0
  41. package/dist/__tests__/providers/pi/durable/PiStreamParser.test.js +34 -0
  42. package/dist/__tests__/providers/pi/durable/PiStreamParser.test.js.map +1 -0
  43. package/dist/__tests__/utils/ClaudeSessionParser.test.js +2 -2
  44. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -1
  45. package/dist/adapters/index.d.ts +1 -1
  46. package/dist/adapters/index.d.ts.map +1 -1
  47. package/dist/adapters/index.js +1 -1
  48. package/dist/adapters/index.js.map +1 -1
  49. package/dist/capacity/codex.d.ts +36 -0
  50. package/dist/capacity/codex.d.ts.map +1 -0
  51. package/dist/capacity/codex.js +317 -0
  52. package/dist/capacity/codex.js.map +1 -0
  53. package/dist/capacity/index.d.ts +11 -0
  54. package/dist/capacity/index.d.ts.map +1 -0
  55. package/dist/capacity/index.js +49 -0
  56. package/dist/capacity/index.js.map +1 -0
  57. package/dist/capacity/types.d.ts +17 -0
  58. package/dist/capacity/types.d.ts.map +1 -0
  59. package/dist/capacity/types.js +3 -0
  60. package/dist/capacity/types.js.map +1 -0
  61. package/dist/database/migrations/004_codex_durable_sessions.sql +1 -0
  62. package/dist/durable/DurableAgent.d.ts +23 -3
  63. package/dist/durable/DurableAgent.d.ts.map +1 -1
  64. package/dist/durable/DurableAgent.js +12 -0
  65. package/dist/durable/DurableAgent.js.map +1 -1
  66. package/dist/durable/DurableAgentRepository.d.ts +6 -7
  67. package/dist/durable/DurableAgentRepository.d.ts.map +1 -1
  68. package/dist/durable/DurableAgentRepository.js +73 -42
  69. package/dist/durable/DurableAgentRepository.js.map +1 -1
  70. package/dist/durable/process.d.ts +8 -0
  71. package/dist/durable/process.d.ts.map +1 -0
  72. package/dist/durable/process.js +40 -0
  73. package/dist/durable/process.js.map +1 -0
  74. package/dist/durable/run.d.ts +32 -0
  75. package/dist/durable/run.d.ts.map +1 -0
  76. package/dist/durable/run.js +46 -0
  77. package/dist/durable/run.js.map +1 -0
  78. package/dist/durable/utils.d.ts +19 -0
  79. package/dist/durable/utils.d.ts.map +1 -0
  80. package/dist/durable/utils.js +52 -0
  81. package/dist/durable/utils.js.map +1 -0
  82. package/dist/index.d.ts +19 -13
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +10 -8
  85. package/dist/index.js.map +1 -1
  86. package/dist/providers/claude/ClaudeAgentMapper.d.ts +21 -0
  87. package/dist/providers/claude/ClaudeAgentMapper.d.ts.map +1 -0
  88. package/dist/providers/claude/ClaudeAgentMapper.js +42 -0
  89. package/dist/providers/claude/ClaudeAgentMapper.js.map +1 -0
  90. package/dist/providers/claude/ClaudeCodeAdapter.d.ts +29 -0
  91. package/dist/providers/claude/ClaudeCodeAdapter.d.ts.map +1 -0
  92. package/dist/providers/claude/ClaudeCodeAdapter.js +129 -0
  93. package/dist/providers/claude/ClaudeCodeAdapter.js.map +1 -0
  94. package/dist/{adapters/ClaudeCodeAdapter.d.ts → providers/claude/ClaudeSessionLocator.d.ts} +68 -52
  95. package/dist/providers/claude/ClaudeSessionLocator.d.ts.map +1 -0
  96. package/dist/{adapters/ClaudeCodeAdapter.js → providers/claude/ClaudeSessionLocator.js} +71 -189
  97. package/dist/providers/claude/ClaudeSessionLocator.js.map +1 -0
  98. package/dist/{utils → providers/claude}/ClaudeSessionParser.d.ts +2 -2
  99. package/dist/providers/claude/ClaudeSessionParser.d.ts.map +1 -0
  100. package/dist/{utils → providers/claude}/ClaudeSessionParser.js +1 -1
  101. package/dist/providers/claude/ClaudeSessionParser.js.map +1 -0
  102. package/dist/providers/claude/durable/ClaudeCliProbe.d.ts.map +1 -0
  103. package/dist/{durable → providers/claude/durable}/ClaudeCliProbe.js +4 -9
  104. package/dist/providers/claude/durable/ClaudeCliProbe.js.map +1 -0
  105. package/dist/{durable → providers/claude/durable}/ClaudePrintAgentService.d.ts +4 -9
  106. package/dist/providers/claude/durable/ClaudePrintAgentService.d.ts.map +1 -0
  107. package/dist/providers/claude/durable/ClaudePrintAgentService.js +45 -0
  108. package/dist/providers/claude/durable/ClaudePrintAgentService.js.map +1 -0
  109. package/dist/{durable → providers/claude/durable}/ClaudePrintRunner.d.ts +3 -3
  110. package/dist/providers/claude/durable/ClaudePrintRunner.d.ts.map +1 -0
  111. package/dist/providers/claude/durable/ClaudePrintRunner.js +75 -0
  112. package/dist/providers/claude/durable/ClaudePrintRunner.js.map +1 -0
  113. package/dist/providers/claude/durable/ClaudeStreamParser.d.ts +17 -0
  114. package/dist/providers/claude/durable/ClaudeStreamParser.d.ts.map +1 -0
  115. package/dist/providers/claude/durable/ClaudeStreamParser.js +69 -0
  116. package/dist/providers/claude/durable/ClaudeStreamParser.js.map +1 -0
  117. package/dist/providers/codex/durable/CodexCliProbe.d.ts +20 -0
  118. package/dist/providers/codex/durable/CodexCliProbe.d.ts.map +1 -0
  119. package/dist/providers/codex/durable/CodexCliProbe.js +61 -0
  120. package/dist/providers/codex/durable/CodexCliProbe.js.map +1 -0
  121. package/dist/providers/codex/durable/CodexPrintAgentService.d.ts +40 -0
  122. package/dist/providers/codex/durable/CodexPrintAgentService.d.ts.map +1 -0
  123. package/dist/providers/codex/durable/CodexPrintAgentService.js +50 -0
  124. package/dist/providers/codex/durable/CodexPrintAgentService.js.map +1 -0
  125. package/dist/providers/codex/durable/CodexPrintRunner.d.ts +34 -0
  126. package/dist/providers/codex/durable/CodexPrintRunner.d.ts.map +1 -0
  127. package/dist/providers/codex/durable/CodexPrintRunner.js +78 -0
  128. package/dist/providers/codex/durable/CodexPrintRunner.js.map +1 -0
  129. package/dist/providers/codex/durable/CodexStreamParser.d.ts +21 -0
  130. package/dist/providers/codex/durable/CodexStreamParser.d.ts.map +1 -0
  131. package/dist/providers/codex/durable/CodexStreamParser.js +86 -0
  132. package/dist/providers/codex/durable/CodexStreamParser.js.map +1 -0
  133. package/dist/providers/pi/durable/PiCliProbe.d.ts +20 -0
  134. package/dist/providers/pi/durable/PiCliProbe.d.ts.map +1 -0
  135. package/dist/providers/pi/durable/PiCliProbe.js +51 -0
  136. package/dist/providers/pi/durable/PiCliProbe.js.map +1 -0
  137. package/dist/providers/pi/durable/PiPrintAgentService.d.ts +39 -0
  138. package/dist/providers/pi/durable/PiPrintAgentService.d.ts.map +1 -0
  139. package/dist/providers/pi/durable/PiPrintAgentService.js +47 -0
  140. package/dist/providers/pi/durable/PiPrintAgentService.js.map +1 -0
  141. package/dist/providers/pi/durable/PiPrintProtocol.d.ts +4 -0
  142. package/dist/providers/pi/durable/PiPrintProtocol.d.ts.map +1 -0
  143. package/dist/providers/pi/durable/PiPrintProtocol.js +44 -0
  144. package/dist/providers/pi/durable/PiPrintProtocol.js.map +1 -0
  145. package/dist/providers/pi/durable/PiPrintRunner.d.ts +32 -0
  146. package/dist/providers/pi/durable/PiPrintRunner.d.ts.map +1 -0
  147. package/dist/providers/pi/durable/PiPrintRunner.js +61 -0
  148. package/dist/providers/pi/durable/PiPrintRunner.js.map +1 -0
  149. package/dist/providers/pi/durable/PiStreamParser.d.ts +21 -0
  150. package/dist/providers/pi/durable/PiStreamParser.d.ts.map +1 -0
  151. package/dist/providers/pi/durable/PiStreamParser.js +71 -0
  152. package/dist/providers/pi/durable/PiStreamParser.js.map +1 -0
  153. package/package.json +1 -1
  154. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +1 -307
  155. package/src/__tests__/capacity/codex.test.ts +184 -0
  156. package/src/__tests__/capacity/index.test.ts +38 -0
  157. package/src/__tests__/database/DurableAgentsDatabase.test.ts +28 -3
  158. package/src/__tests__/durable/CodexCliProbe.test.ts +63 -0
  159. package/src/__tests__/durable/CodexDurableAgentRepository.test.ts +54 -0
  160. package/src/__tests__/durable/CodexPrintAgent.integration.test.ts +79 -0
  161. package/src/__tests__/durable/CodexPrintAgentService.test.ts +139 -0
  162. package/src/__tests__/durable/CodexPrintRunner.test.ts +189 -0
  163. package/src/__tests__/fixtures/fake-codex.cjs +45 -0
  164. package/src/__tests__/print/ClaudeCliProbe.test.ts +3 -7
  165. package/src/__tests__/print/ClaudePrintAgent.integration.test.ts +19 -22
  166. package/src/__tests__/print/ClaudePrintAgentService.test.ts +33 -1
  167. package/src/__tests__/print/ClaudePrintRunner.test.ts +4 -10
  168. package/src/__tests__/providers/claude/ClaudeAgentMapper.test.ts +84 -0
  169. package/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts +411 -0
  170. package/src/__tests__/providers/pi/durable/PiCliProbe.test.ts +38 -0
  171. package/src/__tests__/providers/pi/durable/PiDurableAgentRepository.test.ts +32 -0
  172. package/src/__tests__/providers/pi/durable/PiPrintAgentService.test.ts +69 -0
  173. package/src/__tests__/providers/pi/durable/PiPrintProtocol.test.ts +33 -0
  174. package/src/__tests__/providers/pi/durable/PiPrintRunner.test.ts +107 -0
  175. package/src/__tests__/providers/pi/durable/PiStreamParser.test.ts +24 -0
  176. package/src/__tests__/utils/ClaudeSessionParser.test.ts +2 -2
  177. package/src/adapters/index.ts +1 -1
  178. package/src/capacity/codex.ts +320 -0
  179. package/src/capacity/index.ts +57 -0
  180. package/src/capacity/types.ts +18 -0
  181. package/src/database/migrations/004_codex_durable_sessions.sql +1 -0
  182. package/src/durable/DurableAgent.ts +51 -3
  183. package/src/durable/DurableAgentRepository.ts +60 -31
  184. package/src/durable/process.ts +28 -0
  185. package/src/durable/run.ts +74 -0
  186. package/src/durable/utils.ts +61 -0
  187. package/src/index.ts +37 -15
  188. package/src/providers/claude/ClaudeAgentMapper.ts +62 -0
  189. package/src/providers/claude/ClaudeCodeAdapter.ts +160 -0
  190. package/src/{adapters/ClaudeCodeAdapter.ts → providers/claude/ClaudeSessionLocator.ts} +97 -239
  191. package/src/{utils → providers/claude}/ClaudeSessionParser.ts +2 -2
  192. package/src/{durable → providers/claude/durable}/ClaudeCliProbe.ts +4 -10
  193. package/src/providers/claude/durable/ClaudePrintAgentService.ts +66 -0
  194. package/src/providers/claude/durable/ClaudePrintRunner.ts +92 -0
  195. package/src/providers/claude/durable/ClaudeStreamParser.ts +79 -0
  196. package/src/providers/codex/durable/CodexCliProbe.ts +60 -0
  197. package/src/providers/codex/durable/CodexPrintAgentService.ts +65 -0
  198. package/src/providers/codex/durable/CodexPrintRunner.ts +94 -0
  199. package/src/providers/codex/durable/CodexStreamParser.ts +104 -0
  200. package/src/providers/pi/durable/PiCliProbe.ts +48 -0
  201. package/src/providers/pi/durable/PiPrintAgentService.ts +69 -0
  202. package/src/providers/pi/durable/PiPrintProtocol.ts +39 -0
  203. package/src/providers/pi/durable/PiPrintRunner.ts +88 -0
  204. package/src/providers/pi/durable/PiStreamParser.ts +84 -0
  205. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +0 -1
  206. package/dist/adapters/ClaudeCodeAdapter.js.map +0 -1
  207. package/dist/durable/ClaudeCliProbe.d.ts.map +0 -1
  208. package/dist/durable/ClaudeCliProbe.js.map +0 -1
  209. package/dist/durable/ClaudePrintAgentService.d.ts.map +0 -1
  210. package/dist/durable/ClaudePrintAgentService.js +0 -66
  211. package/dist/durable/ClaudePrintAgentService.js.map +0 -1
  212. package/dist/durable/ClaudePrintRunner.d.ts.map +0 -1
  213. package/dist/durable/ClaudePrintRunner.js +0 -128
  214. package/dist/durable/ClaudePrintRunner.js.map +0 -1
  215. package/dist/utils/ClaudeSessionParser.d.ts.map +0 -1
  216. package/dist/utils/ClaudeSessionParser.js.map +0 -1
  217. package/src/durable/ClaudePrintAgentService.ts +0 -94
  218. package/src/durable/ClaudePrintRunner.ts +0 -139
  219. /package/dist/{durable → providers/claude/durable}/ClaudeCliProbe.d.ts +0 -0
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ const SESSION = '22222222-2222-4222-8222-222222222222';
3
+ const base = { id: 'id', name: 'reviewer', provider: 'pi', providerSessionId: SESSION, sessionHealth: 'uninitialized' };
4
+
5
+ describe('PiPrintAgentService', () => {
6
+ it('probes before provider-aware create', async () => {
7
+ const api = await import('../../../../index.js') as Record<string, unknown>; expect(api).toHaveProperty('PiPrintAgentService');
8
+ const probe = { validate: vi.fn() }; const repository = { create: vi.fn().mockResolvedValue(base), list: vi.fn() }; const runner = { run: vi.fn() };
9
+ const Service = api.PiPrintAgentService as new (options: unknown) => any;
10
+ await new Service({ repository, probe, runner }).create({ name: 'reviewer', cwd: '/project' });
11
+ expect(probe.validate).toHaveBeenCalledOnce(); expect(repository.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project', provider: 'pi' });
12
+ });
13
+
14
+ it('records successful first and resumed sends', async () => {
15
+ const api = await import('../../../../index.js') as Record<string, unknown>;
16
+ const repository = { list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn()
17
+ .mockResolvedValueOnce({ agent: base, token: 'one' }).mockResolvedValueOnce({ agent: { ...base, sessionHealth: 'healthy' }, token: 'two' }),
18
+ recordProviderProcess: vi.fn(), completeRun: vi.fn() };
19
+ const runner = { run: vi.fn(async (request) => { await request.onSpawn({ pid: 42, startedAt: 'start' }); return { sessionId: SESSION, result: 'answer', messages: ['answer'], exitCode: 0 }; }) };
20
+ const Service = api.PiPrintAgentService as new (options: unknown) => any; const service = new Service({ repository, probe: { validate: vi.fn() }, runner });
21
+ await service.send('reviewer', 'first'); await service.send('reviewer', 'later');
22
+ expect(repository.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ status: 'succeeded', sessionHealth: 'healthy' }));
23
+ });
24
+
25
+ it('records mismatches and rejects missing and ambiguous records', async () => {
26
+ const api = await import('../../../../index.js') as Record<string, unknown>; const ErrorType = api.PiPrintError as new (message: string, code: string) => Error;
27
+ const completeRun = vi.fn(); const repository = { list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), recordProviderProcess: vi.fn(), completeRun };
28
+ const Service = api.PiPrintAgentService as new (options: unknown) => any;
29
+ await expect(new Service({ repository, probe: { validate: vi.fn() }, runner: { run: vi.fn().mockRejectedValue(new ErrorType('bad', 'PI_SESSION_MISMATCH')) } }).send('reviewer', 'x'))
30
+ .rejects.toMatchObject({ code: 'PI_SESSION_MISMATCH' });
31
+ expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' }));
32
+ const earlyRepository = { ...repository, resolve: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce([base, base]), acquireRun: vi.fn() };
33
+ const early = new Service({ repository: earlyRepository, probe: { validate: vi.fn() }, runner: { run: vi.fn() } });
34
+ await expect(early.send('missing', 'x')).rejects.toMatchObject({ code: 'DURABLE_AGENT_NOT_FOUND' });
35
+ await expect(early.send('many', 'x')).rejects.toMatchObject({ code: 'PI_UNSUPPORTED' });
36
+ });
37
+
38
+ it('rejects a non-Pi target without acquiring or mutating it', async () => {
39
+ const api = await import('../../../../index.js') as Record<string, unknown>;
40
+ const Service = api.PiPrintAgentService as new (options: unknown) => any;
41
+ const repository = {
42
+ resolve: vi.fn().mockResolvedValue({ ...base, provider: 'claude' }),
43
+ acquireRun: vi.fn(), recordProviderProcess: vi.fn(), completeRun: vi.fn(),
44
+ };
45
+
46
+ await expect(new Service({ repository, probe: { validate: vi.fn() }, runner: { run: vi.fn() } })
47
+ .send('reviewer', 'x')).rejects.toMatchObject({ code: 'PI_UNSUPPORTED' });
48
+ expect(repository.acquireRun).not.toHaveBeenCalled();
49
+ expect(repository.completeRun).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('does not turn a successful completion write failure into a second completion', async () => {
53
+ const api = await import('../../../../index.js') as Record<string, unknown>;
54
+ const Service = api.PiPrintAgentService as new (options: unknown) => any;
55
+ const completionFailure = new Error('completion failed');
56
+ const repository = {
57
+ resolve: vi.fn().mockResolvedValue(base),
58
+ acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }),
59
+ recordProviderProcess: vi.fn(), completeRun: vi.fn().mockRejectedValue(completionFailure),
60
+ };
61
+ const runner = { run: vi.fn().mockResolvedValue({
62
+ sessionId: SESSION, result: 'answer', messages: ['answer'], exitCode: 0,
63
+ }) };
64
+
65
+ await expect(new Service({ repository, probe: { validate: vi.fn() }, runner })
66
+ .send('reviewer', 'x')).rejects.toBe(completionFailure);
67
+ expect(repository.completeRun).toHaveBeenCalledOnce();
68
+ });
69
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildPiPrintArgs, readPiAssistantText, readPiSessionId } from '../../../../providers/pi/durable/PiPrintProtocol.js';
3
+
4
+ const SESSION = '22222222-2222-4222-8222-222222222222';
5
+
6
+ describe('Pi print pure protocol mapping', () => {
7
+ it('maps first and resumed runs to exact documented arguments', () => {
8
+ expect(buildPiPrintArgs(SESSION, true)).toEqual(['--mode', 'json', '--session-id', SESSION]);
9
+ expect(buildPiPrintArgs(SESSION, false)).toEqual(['--mode', 'json', '--session', SESSION]);
10
+ });
11
+
12
+ it('accepts one expected UUID and rejects invalid, duplicate, and mismatched identities', () => {
13
+ expect(readPiSessionId({ id: SESSION }, null, SESSION)).toBe(SESSION);
14
+ expect(() => readPiSessionId({}, null, SESSION)).toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL' }));
15
+ expect(() => readPiSessionId({ id: [SESSION] }, null, SESSION))
16
+ .toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL' }));
17
+ expect(() => readPiSessionId({ id: SESSION }, SESSION, SESSION)).toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL' }));
18
+ expect(() => readPiSessionId({ id: '33333333-3333-4333-8333-333333333333' }, null, SESSION))
19
+ .toThrowError(expect.objectContaining({ code: 'PI_SESSION_MISMATCH' }));
20
+ });
21
+
22
+ it('extracts only non-empty completed assistant text', () => {
23
+ expect(readPiAssistantText({ type: 'future' })).toBeNull();
24
+ expect(readPiAssistantText({ type: 'message_end' })).toBeNull();
25
+ expect(readPiAssistantText({ type: 'message_end', message: [] })).toBeNull();
26
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'user', content: 'no' } })).toBeNull();
27
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: ' ' } })).toBeNull();
28
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: 'answer' } })).toBe('answer');
29
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: null } })).toBeNull();
30
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: [null, [], { type: 'thinking' }] } })).toBeNull();
31
+ expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'a' }, { type: 'text', text: 1 }, { type: 'text', text: 'b' }] } })).toBe('ab');
32
+ });
33
+ });
@@ -0,0 +1,107 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { PassThrough, Writable } from 'node:stream';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { PiDurableAgent } from '../../../../index.js';
5
+
6
+ const SESSION = '22222222-2222-4222-8222-222222222222';
7
+ function agent(sessionHealth: PiDurableAgent['sessionHealth'] = 'uninitialized'): PiDurableAgent {
8
+ return { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'pi', mode: 'durable',
9
+ cwd: '/project', providerSessionId: SESSION, state: 'running', sessionHealth, createdAt: '',
10
+ updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null };
11
+ }
12
+ function fakeSpawn(lines: string[], exitCode = 0) {
13
+ const promptChunks: Buffer[] = [];
14
+ const child = new EventEmitter() as any;
15
+ child.pid = 4242; child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = vi.fn();
16
+ child.stdin = new Writable({
17
+ write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); },
18
+ final(callback) { child.stdout.end(lines.join('\n')); queueMicrotask(() => child.emit('close', exitCode, null)); callback(); },
19
+ });
20
+ return { child, spawn: vi.fn(() => child), promptChunks };
21
+ }
22
+ function events(session = SESSION): string[] { return [
23
+ JSON.stringify({ type: 'session', version: 3, id: session, cwd: '/project' }),
24
+ JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } }),
25
+ JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'hidden' }, { type: 'text', text: 'final' }] } }),
26
+ JSON.stringify({ type: 'agent_end', messages: [] }), '',
27
+ ]; }
28
+ async function runner(fixture: ReturnType<typeof fakeSpawn>, maxLineBytes?: number) {
29
+ const api = await import('../../../../index.js') as Record<string, unknown>;
30
+ expect(api).toHaveProperty('PiPrintRunner');
31
+ const Runner = api.PiPrintRunner as new (options: unknown) => any;
32
+ return new Runner({ spawn: fixture.spawn, maxLineBytes, processInspector: { getIdentity: () => ({ pid: 4242, startedAt: 'start' }) } });
33
+ }
34
+
35
+ describe('PiPrintRunner', () => {
36
+ it('binds a first session and returns the last completed assistant text', async () => {
37
+ const fixture = fakeSpawn(events()); const instance = await runner(fixture); const order: string[] = [];
38
+ const result = await instance.run({ agent: agent(), prompt: 'secret', executable: 'fake-pi',
39
+ onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); order.push('spawn'); } });
40
+ expect(order).toEqual(['spawn']);
41
+ expect(fixture.spawn).toHaveBeenCalledWith('fake-pi', ['--mode', 'json', '--session-id', SESSION], expect.objectContaining({ cwd: '/project', shell: false }));
42
+ expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret');
43
+ expect(result).toEqual({ sessionId: SESSION, result: 'final', messages: ['first', 'final'], exitCode: 0 });
44
+ });
45
+
46
+ it('resumes the exact stored session and rejects mismatch', async () => {
47
+ const fixture = fakeSpawn(events('33333333-3333-4333-8333-333333333333'));
48
+ await expect((await runner(fixture)).run({ agent: agent('healthy'), prompt: 'later', onSpawn: vi.fn() }))
49
+ .rejects.toMatchObject({ code: 'PI_SESSION_MISMATCH' });
50
+ expect(fixture.spawn.mock.calls[0]![1]).toEqual(['--mode', 'json', '--session', SESSION]);
51
+ });
52
+
53
+ it.each([
54
+ ['malformed', ['{bad\n'], 'PI_PROTOCOL'], ['non-object', ['[]\n'], 'PI_PROTOCOL'],
55
+ ['truncated', ['{}'], 'PI_PROTOCOL'], ['missing session', [JSON.stringify({ type: 'agent_end' }), ''], 'PI_PROTOCOL'],
56
+ ['missing result', [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'agent_end' }), ''], 'PI_RESULT_MISSING'],
57
+ ['missing end', [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'x' }] } }), ''], 'PI_PROTOCOL'],
58
+ ])('rejects %s output', async (_name, lines, code) => {
59
+ await expect((await runner(fakeSpawn(lines as string[]))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() }))
60
+ .rejects.toMatchObject({ code });
61
+ });
62
+
63
+ it('rejects oversized output, failed processes, and unverifiable identities', async () => {
64
+ await expect((await runner(fakeSpawn(['x'.repeat(20)]), 10)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() }))
65
+ .rejects.toMatchObject({ code: 'PI_PROTOCOL' });
66
+ await expect((await runner(fakeSpawn(events(), 1))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() }))
67
+ .rejects.toMatchObject({ code: 'PI_PROCESS' });
68
+ const fixture = fakeSpawn([]); const api = await import('../../../../index.js') as Record<string, unknown>;
69
+ const Runner = api.PiPrintRunner as new (options: unknown) => any;
70
+ await expect(new Runner({ spawn: fixture.spawn, processInspector: { getIdentity: () => null } }).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() }))
71
+ .rejects.toMatchObject({ code: 'PI_PROCESS' });
72
+ expect(fixture.child.kill).toHaveBeenCalledOnce();
73
+ });
74
+
75
+ it('rejects invalid and duplicate session identities', async () => {
76
+ for (const lines of [
77
+ [JSON.stringify({ type: 'session', id: 'bad' }), ''],
78
+ [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'session', id: SESSION }), ''],
79
+ ]) await expect((await runner(fakeSpawn(lines))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() }))
80
+ .rejects.toMatchObject({ code: 'PI_PROTOCOL' });
81
+ });
82
+
83
+ it('kills on spawn persistence failure', async () => {
84
+ const spawnFailure = fakeSpawn([]); const failure = new Error('cannot persist');
85
+ await expect((await runner(spawnFailure)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn().mockRejectedValue(failure), onSession: vi.fn() })).rejects.toBe(failure);
86
+ expect(spawnFailure.child.kill).toHaveBeenCalledOnce();
87
+ });
88
+
89
+ it('classifies spawn errors and a missing PID as process failures', async () => {
90
+ const errored = fakeSpawn([]);
91
+ errored.child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); }, final(callback) { queueMicrotask(() => errored.child.emit('error', new Error('spawn'))); callback(); } });
92
+ await expect((await runner(errored)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })).rejects.toMatchObject({ code: 'PI_PROCESS' });
93
+ const missing = fakeSpawn([]); missing.child.pid = undefined;
94
+ await expect((await runner(missing)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })).rejects.toMatchObject({ code: 'PI_PROCESS' });
95
+ expect(missing.child.kill).toHaveBeenCalledOnce();
96
+ });
97
+
98
+ it('accepts string assistant content and ignores empty or unrelated messages', async () => {
99
+ const lines = [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'future' }),
100
+ JSON.stringify({ type: 'message_end', message: { role: 'user', content: 'ignored' } }),
101
+ JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: ' ' } }),
102
+ JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: 'answer' } }),
103
+ JSON.stringify({ type: 'agent_end' }), ''];
104
+ await expect((await runner(fakeSpawn(lines))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() }))
105
+ .resolves.toMatchObject({ result: 'answer' });
106
+ });
107
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PiStreamParser } from '../../../../providers/pi/durable/PiStreamParser.js';
3
+
4
+ const SESSION = '22222222-2222-4222-8222-222222222222';
5
+
6
+ describe('PiStreamParser', () => {
7
+ it('ignores blank lines and normalizes unexpected parser failures', () => {
8
+ const parser = new PiStreamParser(SESSION, 1024);
9
+ parser.accept('\n');
10
+ parser.fail(new Error('unexpected'));
11
+
12
+ expect(parser.hasFailed()).toBe(true);
13
+ expect(() => parser.result({ code: 0, signal: null }))
14
+ .toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL', message: 'Pi stream processing failed.' }));
15
+ });
16
+
17
+ it('includes the terminating signal in process failures', () => {
18
+ const parser = new PiStreamParser(SESSION, 1024);
19
+ parser.accept(`${JSON.stringify({ type: 'session', id: SESSION })}\n`);
20
+
21
+ expect(() => parser.result({ code: null, signal: 'SIGTERM' }))
22
+ .toThrowError(expect.objectContaining({ code: 'PI_PROCESS', message: 'Pi print run failed (SIGTERM)' }));
23
+ });
24
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for utils/ClaudeSessionParser.ts — focused on stripping
2
+ * Tests for providers/claude/ClaudeSessionParser.ts — focused on stripping
3
3
  * harness-injected XML tags from conversation content.
4
4
  */
5
5
 
@@ -7,7 +7,7 @@
7
7
  import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import * as path from 'path';
10
- import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser.js';
10
+ import { ClaudeSessionParser } from '../../providers/claude/ClaudeSessionParser.js';
11
11
 
12
12
  interface JsonlEntry {
13
13
  type: 'user' | 'assistant' | 'system';
@@ -1,4 +1,4 @@
1
- export { ClaudeCodeAdapter } from './ClaudeCodeAdapter.js';
1
+ export { ClaudeCodeAdapter } from '../providers/claude/ClaudeCodeAdapter.js';
2
2
  export { CodexAdapter } from './CodexAdapter.js';
3
3
  export { CopilotAdapter } from './CopilotAdapter.js';
4
4
  export { GeminiCliAdapter } from './GeminiCliAdapter.js';
@@ -0,0 +1,320 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { CapacityReport, CapacityWindow } from './types.js';
5
+
6
+ type CodexUsageSource = 'pat' | 'oauth' | 'cli';
7
+ type UsageSnapshot = { windows: CapacityWindow[]; creditsRemaining: number | null; source: CodexUsageSource };
8
+
9
+ type UnknownRecord = Record<string, unknown>;
10
+ type RpcMessage = { id?: number; method: string; params?: UnknownRecord };
11
+ type CliResponses = { rateLimits: unknown; account: unknown };
12
+ type CodexRpc = (messages: RpcMessage[]) => Promise<CliResponses>;
13
+
14
+ export const CODEX_APP_SERVER_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const;
15
+
16
+ type CodexProbeOptions = {
17
+ installed: boolean;
18
+ checkedAt: string;
19
+ readFile?: (path: string, encoding: BufferEncoding) => Promise<string>;
20
+ fetch?: typeof globalThis.fetch;
21
+ rpc?: CodexRpc;
22
+ timeoutMs?: number;
23
+ env?: NodeJS.ProcessEnv;
24
+ now?: () => Date;
25
+ };
26
+
27
+ function record(value: unknown): UnknownRecord | null {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ ? value as UnknownRecord
30
+ : null;
31
+ }
32
+
33
+ function finiteNumber(value: unknown): number | null {
34
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
35
+ }
36
+
37
+ function nonEmptyText(value: unknown): string | null {
38
+ return typeof value === 'string' && value.length > 0 ? value : null;
39
+ }
40
+
41
+ function resetTime(value: unknown): string | null {
42
+ const seconds = finiteNumber(value);
43
+ if (seconds !== null) return new Date(seconds * 1000).toISOString();
44
+ if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
45
+ return null;
46
+ }
47
+
48
+ function safeIdentifier(value: unknown): string | null {
49
+ const candidate = nonEmptyText(value);
50
+ if (!candidate || !/^[a-z][a-z0-9_-]{0,63}$/i.test(candidate)) return null;
51
+ if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null;
52
+ return candidate;
53
+ }
54
+
55
+ export function resolveCodexAuthPath(env: NodeJS.ProcessEnv = process.env): string {
56
+ const root = env.CODEX_HOME || join(env.HOME || '', '.codex');
57
+ return join(root, 'auth.json');
58
+ }
59
+
60
+ export function toRateWindow(
61
+ value: unknown,
62
+ id: string,
63
+ label: string
64
+ ): CapacityWindow | null {
65
+ const input = record(value);
66
+ if (!input) return null;
67
+ const used = finiteNumber(input.used_percent);
68
+ const seconds = finiteNumber(input.limit_window_seconds);
69
+ return {
70
+ id,
71
+ label,
72
+ durationMinutes: seconds === null ? null : seconds / 60,
73
+ usedPercent: used,
74
+ resetsAt: resetTime(input.reset_at)
75
+ };
76
+ }
77
+
78
+ function extraWindows(value: unknown): CapacityWindow[] {
79
+ if (!Array.isArray(value)) return [];
80
+ return value.flatMap((entry, index) => {
81
+ const limit = record(entry);
82
+ if (!limit) return [];
83
+ const scope = safeIdentifier(limit.limit_name) ?? `extra-${index + 1}`;
84
+ const windows = record(limit.rate_limit) ?? limit;
85
+ return [
86
+ toRateWindow(windows.primary_window, `${scope}:primary`, `${scope} primary`),
87
+ toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`)
88
+ ].filter((window): window is CapacityWindow => window !== null);
89
+ });
90
+ }
91
+
92
+ export function parseUsage(raw: unknown, source: 'pat' | 'oauth'): UsageSnapshot {
93
+ const response = record(raw) ?? {};
94
+ const limits = record(response.rate_limit) ?? {};
95
+ const credits = record(response.credits) ?? {};
96
+ return {
97
+ windows: [
98
+ toRateWindow(limits.primary_window, 'session', 'Session'),
99
+ toRateWindow(limits.secondary_window, 'weekly', 'Weekly'),
100
+ ...extraWindows(response.additional_rate_limits)
101
+ ].filter((window): window is CapacityWindow => window !== null),
102
+ creditsRemaining: finiteNumber(credits.balance),
103
+ source
104
+ };
105
+ }
106
+
107
+ function cliWindow(value: unknown, id: string, label: string): CapacityWindow | null {
108
+ const input = record(value);
109
+ if (!input) return null;
110
+ return {
111
+ id,
112
+ label,
113
+ durationMinutes: finiteNumber(input.windowDurationMins),
114
+ usedPercent: finiteNumber(input.usedPercent),
115
+ resetsAt: resetTime(input.resetsAt)
116
+ };
117
+ }
118
+
119
+ function cliSnapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] {
120
+ const snapshot = record(value);
121
+ if (!snapshot) return [];
122
+ const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex';
123
+ return [
124
+ cliWindow(snapshot.primary, `${scope}:primary`, `${scope} primary`),
125
+ cliWindow(snapshot.secondary, `${scope}:secondary`, `${scope} secondary`)
126
+ ].filter((item): item is CapacityWindow => item !== null);
127
+ }
128
+
129
+ export function parseCliUsage(raw: unknown): UsageSnapshot {
130
+ const response = record(raw) ?? {};
131
+ const primary = record(response.rateLimits);
132
+ const windows = primary ? cliSnapshotWindows(primary, 'codex') : [];
133
+ const buckets = record(response.rateLimitsByLimitId);
134
+ if (buckets) {
135
+ for (const [id, snapshot] of Object.entries(buckets)) windows.push(...cliSnapshotWindows(snapshot, id));
136
+ }
137
+ const unique = [...new Map(windows.map(window => [window.id, window])).values()];
138
+ return { windows: unique, creditsRemaining: null, source: 'cli' };
139
+ }
140
+
141
+ function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): CapacityReport {
142
+ const hasUsage = snapshot.windows.some(window => window.usedPercent !== null);
143
+ const rateLimits = record(record(raw)?.rateLimits);
144
+ const reached = nonEmptyText(rateLimits?.rateLimitReachedType);
145
+ const resetCredits = record(record(raw)?.rateLimitResetCredits) ?? record(record(raw)?.usageLimitResetCredits);
146
+ return {
147
+ provider: 'codex',
148
+ generatedAt: context.checkedAt,
149
+ authenticated: true,
150
+ available: reached ? 'no' : hasUsage ? 'yes' : 'unknown',
151
+ windows: snapshot.windows,
152
+ creditsRemaining: snapshot.creditsRemaining ?? finiteNumber(resetCredits?.availableCount)
153
+ };
154
+ }
155
+
156
+ function jwtExpiry(token: string): number | null {
157
+ const part = token.split('.')[1];
158
+ if (!part) return null;
159
+ try {
160
+ return finiteNumber(record(JSON.parse(Buffer.from(part, 'base64url').toString('utf8')))?.exp);
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function staleOAuth(tokens: UnknownRecord, token: string, now: Date): boolean {
167
+ const metadata = tokens.expires_at ?? tokens.expiresAt ?? tokens.expiry;
168
+ let expiry: number | null = finiteNumber(metadata);
169
+ if (typeof metadata === 'string') {
170
+ const parsed = Date.parse(metadata);
171
+ expiry = Number.isNaN(parsed) ? null : parsed / 1000;
172
+ }
173
+ expiry ??= jwtExpiry(token);
174
+ return expiry !== null && expiry <= now.getTime() / 1000;
175
+ }
176
+
177
+ async function fetchJson(fetcher: typeof globalThis.fetch, url: string, init: RequestInit, timeoutMs: number): Promise<unknown> {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
180
+ try {
181
+ const response = await fetcher(url, { ...init, signal: controller.signal });
182
+ if (!response.ok) throw new Error(response.status === 401 ? 'unauthorized' : 'request failed');
183
+ return await response.json();
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
187
+ }
188
+
189
+ async function apiSnapshot(
190
+ token: string,
191
+ accountId: string,
192
+ source: 'pat' | 'oauth',
193
+ options: CodexProbeOptions
194
+ ): Promise<UsageSnapshot> {
195
+ const fetcher = options.fetch ?? globalThis.fetch;
196
+ const raw = await fetchJson(fetcher, 'https://chatgpt.com/backend-api/wham/usage', {
197
+ headers: { Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': accountId }
198
+ }, options.timeoutMs ?? 5000);
199
+ return parseUsage(raw, source);
200
+ }
201
+
202
+ function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise<CliResponses> {
203
+ return new Promise((resolve, reject) => {
204
+ const child = spawn('codex', CODEX_APP_SERVER_ARGS, {
205
+ stdio: ['pipe', 'pipe', 'ignore']
206
+ });
207
+ const results: Partial<CliResponses> = {};
208
+ let buffer = '';
209
+ let settled = false;
210
+ const finish = (error?: Error) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ child.kill();
215
+ if (error) reject(error);
216
+ else resolve(results as CliResponses);
217
+ };
218
+ const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs);
219
+ child.once('error', () => finish(new Error('codex app-server unavailable')));
220
+ child.once('exit', () => { if (!settled) finish(new Error('codex app-server exited')); });
221
+ child.stdout.setEncoding('utf8');
222
+ child.stdout.on('data', (chunk: string) => {
223
+ buffer += chunk;
224
+ for (;;) {
225
+ const newline = buffer.indexOf('\n');
226
+ if (newline < 0) break;
227
+ const line = buffer.slice(0, newline).trim();
228
+ buffer = buffer.slice(newline + 1);
229
+ if (!line) continue;
230
+ let message: UnknownRecord;
231
+ try { message = JSON.parse(line) as UnknownRecord; } catch { continue; }
232
+ if (message.id === 1) {
233
+ for (const request of messages.slice(1)) child.stdin.write(`${JSON.stringify(request)}\n`);
234
+ } else if (message.id === 2) {
235
+ if (message.error) finish(new Error('codex rate-limit method failed'));
236
+ else results.rateLimits = message.result;
237
+ } else if (message.id === 3) {
238
+ if (message.error) finish(new Error('codex account method failed'));
239
+ else results.account = message.result;
240
+ }
241
+ if ('rateLimits' in results && 'account' in results) finish();
242
+ }
243
+ });
244
+ child.stdin.write(`${JSON.stringify(messages[0])}\n`);
245
+ });
246
+ }
247
+
248
+ function unavailable(options: CodexProbeOptions): CapacityReport {
249
+ return {
250
+ provider: 'codex',
251
+ generatedAt: options.checkedAt,
252
+ authenticated: null,
253
+ available: 'unknown',
254
+ windows: [],
255
+ creditsRemaining: null
256
+ };
257
+ }
258
+
259
+ async function cliFallback(options: CodexProbeOptions): Promise<CapacityReport> {
260
+ if (!options.installed) return unavailable(options);
261
+ const messages: RpcMessage[] = [
262
+ { id: 1, method: 'initialize', params: {
263
+ clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null
264
+ } },
265
+ { method: 'initialized' },
266
+ { id: 2, method: 'account/rateLimits/read' },
267
+ { id: 3, method: 'account/read' }
268
+ ];
269
+ try {
270
+ const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs));
271
+ const response = await rpc(messages);
272
+ const result = capacityFromSnapshot(parseCliUsage(response.rateLimits), options, response.rateLimits);
273
+ const accountEnvelope = record(response.account);
274
+ if (accountEnvelope && Object.hasOwn(accountEnvelope, 'account') && !record(accountEnvelope.account)) {
275
+ result.authenticated = false;
276
+ result.available = 'unknown';
277
+ }
278
+ return result;
279
+ } catch {
280
+ return unavailable(options);
281
+ }
282
+ }
283
+
284
+ export async function probeCodexCapacity(options: CodexProbeOptions): Promise<CapacityReport> {
285
+ let parsed: UnknownRecord | null = null;
286
+ try {
287
+ const contents = await (options.readFile ?? readFile)(resolveCodexAuthPath(options.env), 'utf8');
288
+ parsed = record(JSON.parse(contents));
289
+ } catch {
290
+ return cliFallback(options);
291
+ }
292
+
293
+ const auth = parsed ?? {};
294
+ const pat = nonEmptyText(auth.personal_access_token);
295
+ if (pat) {
296
+ try {
297
+ const fetcher = options.fetch ?? globalThis.fetch;
298
+ const whoami = record(await fetchJson(fetcher,
299
+ 'https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami',
300
+ { headers: { Authorization: `Bearer ${pat}` } }, options.timeoutMs ?? 5000));
301
+ const accountId = nonEmptyText(whoami?.chatgpt_account_id);
302
+ if (!accountId) throw new Error('account unavailable');
303
+ return capacityFromSnapshot(await apiSnapshot(pat, accountId, 'pat', options), options);
304
+ } catch {
305
+ // Continue to a separately available OAuth credential before using the CLI.
306
+ }
307
+ }
308
+
309
+ const tokens = record(auth.tokens);
310
+ const accessToken = nonEmptyText(tokens?.access_token);
311
+ const accountId = nonEmptyText(tokens?.account_id);
312
+ if (tokens && accessToken && accountId && !staleOAuth(tokens, accessToken, (options.now ?? (() => new Date()))())) {
313
+ try {
314
+ return capacityFromSnapshot(await apiSnapshot(accessToken, accountId, 'oauth', options), options);
315
+ } catch {
316
+ return cliFallback(options);
317
+ }
318
+ }
319
+ return cliFallback(options);
320
+ }
@@ -0,0 +1,57 @@
1
+ import { constants } from 'node:fs';
2
+ import { access as fsAccess } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { probeCodexCapacity } from './codex.js';
5
+ import type { CapacityReport } from './types.js';
6
+
7
+ export type { CapacityReport, CapacityWindow } from './types.js';
8
+
9
+ export type CapacityProbeOptions = {
10
+ now?: () => Date;
11
+ path?: string;
12
+ access?: (target: string) => Promise<void>;
13
+ probe?: typeof probeCodexCapacity;
14
+ };
15
+
16
+ async function canAccess(target: string, mode: number): Promise<boolean> {
17
+ try {
18
+ await fsAccess(target, mode);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function isCodexInstalled(pathValue: string, checkAccess?: (target: string) => Promise<void>): Promise<boolean> {
26
+ const directories = pathValue.split(path.delimiter).filter(Boolean);
27
+ for (const directory of directories) {
28
+ const executable = path.join(directory, 'codex');
29
+ if (checkAccess) {
30
+ try {
31
+ await checkAccess(executable);
32
+ return true;
33
+ } catch {
34
+ continue;
35
+ }
36
+ }
37
+ if (await canAccess(executable, constants.X_OK)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export async function getCodexCapacityReport(options: CapacityProbeOptions = {}): Promise<CapacityReport> {
43
+ const generatedAt = (options.now?.() ?? new Date()).toISOString();
44
+ const installed = await isCodexInstalled(options.path ?? process.env.PATH ?? '', options.access);
45
+ try {
46
+ return await (options.probe ?? probeCodexCapacity)({ installed, checkedAt: generatedAt });
47
+ } catch {
48
+ return {
49
+ provider: 'codex',
50
+ generatedAt,
51
+ authenticated: null,
52
+ available: 'unknown',
53
+ windows: [],
54
+ creditsRemaining: null
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ export type Availability = 'yes' | 'no' | 'unknown';
2
+
3
+ export interface CapacityWindow {
4
+ id: string;
5
+ label: string;
6
+ durationMinutes: number | null;
7
+ usedPercent: number | null;
8
+ resetsAt: string | null;
9
+ }
10
+
11
+ export interface CapacityReport {
12
+ provider: string;
13
+ generatedAt: string;
14
+ authenticated: boolean | null;
15
+ available: Availability;
16
+ windows: CapacityWindow[];
17
+ creditsRemaining: number | null;
18
+ }
@@ -0,0 +1 @@
1
+ ALTER TABLE durable_agents ALTER COLUMN provider_session_id DROP NOT NULL;