@mahmoudwael/opai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahmoud Wael
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,391 @@
1
+ # OPAI
2
+
3
+ **Browse tickets, launch a coding agent, and return to the exact conversation later.**
4
+
5
+ OPAI is a small TypeScript terminal application that connects a ticket provider to Claude Code and OpenAI Codex. It shows your assigned OpenProject work packages and saved queries, launches the selected agent with a focused ticket prompt, and records the agent's native session for reliable resumption.
6
+
7
+ OpenProject is the provider included in V1. The ticket picker, agent launchers, and session store use a common ticket model so another provider can be added without rewriting the rest of the application.
8
+
9
+ ## Workflow
10
+
11
+ ```text
12
+ opai
13
+ -> My tickets
14
+ -> #4521 Fix workflow step execution
15
+ -> Fix with Claude Code
16
+ -> fix openproject bug 4521
17
+
18
+ Later:
19
+
20
+ opai
21
+ -> My sessions
22
+ -> #4521 Fix workflow step execution
23
+ -> Resume Claude Code session
24
+ ```
25
+
26
+ OPAI starts with these built-in prompt templates:
27
+
28
+ ```text
29
+ implement openproject user story <id>
30
+ fix openproject bug <id>
31
+ ```
32
+
33
+ You can edit either template through **Launch defaults** or for one launch. Ticket details and implementation work remain the coding agent's responsibility.
34
+
35
+ ## Why OPAI?
36
+
37
+ Coding agents can work from ticket IDs, but the surrounding workflow is still easy to lose:
38
+
39
+ | Problem | What OPAI does |
40
+ | --- | --- |
41
+ | Finding the right assigned ticket interrupts terminal work | Presents assigned tickets and saved queries in a searchable picker |
42
+ | Recreating ticket context produces long, inconsistent prompts | Starts with a small provider template and previews any edits before launch |
43
+ | Agent conversations become detached from their tickets | Associates each ticket with verified native agent session IDs |
44
+ | Returning later means searching agent history | Resumes the selected native conversation from **My sessions** |
45
+ | Reopening a CLI can repeatedly call the ticket API | Caches ticket lists and queries on disk with explicit refresh actions |
46
+ | Scripts may accidentally modify ticket data | Uses only `GET` requests for OpenProject data |
47
+
48
+ ## Features
49
+
50
+ - Searchable assigned-ticket and saved-query lists
51
+ - Bug and User Story actions based on stable type IDs from your OpenProject instance
52
+ - Interactive Claude Code and Codex processes with normal permission prompts
53
+ - Per-session model, effort, and prompt choices with saved launch defaults
54
+ - Local Codex model discovery with model-specific effort choices
55
+ - Multiple native sessions per ticket
56
+ - Exact-session resume in the original working directory
57
+ - Recovery of existing native sessions whose first prompt matches the ticket exactly
58
+ - Persistent ticket, query, and session data across terminals, tmux sessions, and WSL restarts
59
+ - Eight-hour disk cache by default, plus explicit refresh actions
60
+ - Pinned and recently opened saved queries stored locally
61
+ - Local dashboard for ticket status, agent use, recent activity, and resumable work
62
+ - Read-only OpenProject integration
63
+
64
+ ## Requirements
65
+
66
+ - Node.js 22 or newer
67
+ - npm
68
+ - An OpenProject account with API access
69
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview), [OpenAI Codex](https://github.com/openai/codex), or both
70
+ - Agent-side access to OpenProject for whichever agent you select
71
+
72
+ OPAI is currently developed and tested on Ubuntu under WSL2. It launches agents as normal interactive child processes and does not change their permissions or global configuration.
73
+
74
+ ## Install
75
+
76
+ Install OPAI globally from npm:
77
+
78
+ ```sh
79
+ npm install --global @mahmoudwael/opai
80
+ ```
81
+
82
+ Then run:
83
+
84
+ ```sh
85
+ opai
86
+ ```
87
+
88
+ To install from source instead:
89
+
90
+ ```sh
91
+ git clone https://github.com/MahmoudWael/opai.git
92
+ cd opai
93
+ npm ci
94
+ npm run build
95
+ npm link
96
+ ```
97
+
98
+ Confirm that the command is available:
99
+
100
+ ```sh
101
+ opai
102
+ ```
103
+
104
+ If global npm links are not writable, link the included launcher into a user-owned directory:
105
+
106
+ ```sh
107
+ mkdir -p ~/.local/bin
108
+ ln -sf "$PWD/opai" ~/.local/bin/opai
109
+ ```
110
+
111
+ Ensure `~/.local/bin` is on your `PATH`. For example, add this to `~/.zshrc` or `~/.bashrc`:
112
+
113
+ ```sh
114
+ export PATH="$HOME/.local/bin:$PATH"
115
+ ```
116
+
117
+ Then open a new terminal or reload the shell configuration.
118
+
119
+ ## Configure OpenProject
120
+
121
+ ### 1. Create an API token
122
+
123
+ In OpenProject, open **Account settings -> Access tokens**, select **+ API Token**, and copy the generated token. OpenProject displays a newly created token only once. See the official [OpenProject access-token guide](https://www.openproject.org/docs/user-guide/account-settings/access-tokens/).
124
+
125
+ ### 2. Create the OPAI configuration
126
+
127
+ ```sh
128
+ mkdir -p ~/.config/opai
129
+ cp config.example.json ~/.config/opai/config.json
130
+ ```
131
+
132
+ Edit `~/.config/opai/config.json`:
133
+
134
+ ```json
135
+ {
136
+ "openproject": {
137
+ "url": "https://openproject.example.com",
138
+ "instanceId": "work",
139
+ "bugTypeId": 7,
140
+ "userStoryTypeId": 6,
141
+ "promptTemplates": {
142
+ "bug": "fix openproject bug {{id}}",
143
+ "userStory": "implement openproject user story {{id}}"
144
+ }
145
+ },
146
+ "cacheTtlHours": 8,
147
+ "cwd": "/home/you/projects/your-repository",
148
+ "agents": {
149
+ "claude": "claude",
150
+ "codex": "codex"
151
+ },
152
+ "models": {
153
+ "claude": ["custom-claude-model-id"],
154
+ "codex": ["configured-codex-model-id"]
155
+ }
156
+ }
157
+ ```
158
+
159
+ Replace the sample values with values from your OpenProject instance:
160
+
161
+ | Setting | Required | Meaning |
162
+ | --- | --- | --- |
163
+ | `openproject.url` | Yes | Base URL without `/api/v3` |
164
+ | `openproject.instanceId` | Yes | Stable local name that distinguishes this OpenProject instance in session keys |
165
+ | `openproject.bugTypeId` | Yes | Numeric type ID used by this instance for Bugs |
166
+ | `openproject.userStoryTypeId` | Yes | Numeric type ID used by this instance for User Stories |
167
+ | `openproject.promptTemplates.bug` | No | Default Bug prompt template; must contain `{{id}}` |
168
+ | `openproject.promptTemplates.userStory` | No | Default User Story prompt template; must contain `{{id}}` |
169
+ | `cacheTtlHours` | No | Cache lifetime greater than `0` and at most `168` hours; defaults to `8` |
170
+ | `cwd` | No | Working directory passed to agents; defaults to the directory where `opai` was started |
171
+ | `agents.claude` | No | Claude executable name or absolute path |
172
+ | `agents.codex` | No | Codex executable name or absolute path |
173
+ | `models.claude` | No | Additional Claude model IDs shown alongside Default, Sonnet, Opus, and Haiku |
174
+ | `models.codex` | No | Codex model IDs available in the model preference picker |
175
+
176
+ OpenProject type IDs vary by instance. Read them from `/api/v3/types` or from a work package's `_links.type.href`. OPAI still displays unsupported ticket types, but it offers no Implement or Fix action until their type is mapped.
177
+
178
+ Keep `instanceId` stable after sessions have been recorded. It forms part of the provider-qualified ticket key, such as `openproject@work:4521`.
179
+
180
+ ### 3. Save the token
181
+
182
+ Run `opai`. On the first run, OPAI asks for the token with hidden input and writes it to:
183
+
184
+ ```text
185
+ ~/.config/opai/token
186
+ ```
187
+
188
+ The file is created with owner-only permissions (`0600`) and is reused by future terminals. The token is never written to `config.json`, cache files, or logs.
189
+
190
+ To replace it, delete `~/.config/opai/token` and run OPAI again. `OPENPROJECT_API_TOKEN` can also provide a temporary environment override.
191
+
192
+ ## Usage
193
+
194
+ Start the interactive home screen:
195
+
196
+ ```sh
197
+ opai
198
+ ```
199
+
200
+ Available shortcuts:
201
+
202
+ ```sh
203
+ opai mine # Open assigned tickets
204
+ opai show 4521 # Open one ticket
205
+ opai resume 4521 # Choose a saved session for one ticket
206
+ ```
207
+
208
+ ### Navigation
209
+
210
+ | Key | Action |
211
+ | --- | --- |
212
+ | `Up` / `Down` | Move through a list |
213
+ | Type | Filter tickets, queries, or sessions |
214
+ | `Enter` | Open the selected item |
215
+ | `Esc` | Return to the previous screen |
216
+ | `Ctrl+C` | Exit OPAI |
217
+
218
+ ### Home views
219
+
220
+ - **My tickets** reads assigned, open OpenProject work packages.
221
+ - **Saved queries** reads the queries visible to your OpenProject account. Pinning and recent-query ordering are local preferences and never alter the remote query.
222
+ - **My sessions** opens recorded conversations without fetching tickets again.
223
+ - **Refresh my tickets** bypasses the cache and updates matching statuses in saved sessions.
224
+ - **Refresh saved queries** reloads query names. Each query also has its own result refresh action.
225
+
226
+ ### Launch options and defaults
227
+
228
+ Selecting **Fix/Implement with Claude Code** or **Fix/Implement with Codex** opens a compact launch screen:
229
+
230
+ ```text
231
+ Model Sonnet
232
+ Effort Medium
233
+ Prompt fix openproject bug {{id}}
234
+
235
+ Start session
236
+ Change model
237
+ Change effort
238
+ Edit prompt
239
+ Save current options as defaults
240
+ ```
241
+
242
+ The screen previews the exact resolved prompt before launch. `{{id}}` is replaced with the selected ticket ID, and every prompt template must contain that placeholder. Prompt edits apply only to the current launch unless **Save current options as defaults** is selected.
243
+
244
+ Use **Launch defaults** on the home screen to set model and effort independently for Claude Code and Codex, and to edit the default Bug and User Story prompt templates. Saved prompt defaults belong to the OpenProject instance and apply to both agents.
245
+
246
+ The effective prompt template is chosen in this order:
247
+
248
+ 1. The current launch-screen edit.
249
+ 2. A default saved through OPAI.
250
+ 3. `openproject.promptTemplates` in `config.json`.
251
+ 4. OPAI's built-in OpenProject prompt.
252
+
253
+ Codex models, display names, and model-specific effort choices are read locally from the installed CLI's bundled catalog. Only models marked visible by Codex are shown. Configured IDs from `models.codex` are appended for custom setups or used when discovery is unavailable.
254
+
255
+ Claude Code has no supported model-catalog command. OPAI reads aliases and effort support from the installed CLI help, always includes Sonnet, Opus, and Haiku, and appends IDs from `models.claude`.
256
+
257
+ **Default** omits the corresponding model or effort override and lets the agent use its existing configuration. If an agent rejects a selected value, OPAI reports the native error without switching values silently.
258
+
259
+ The requested model, effort, and resolved initial prompt are saved as historical launch metadata. Resume passes none of these overrides and lets the native session restore its own state.
260
+
261
+ ## Native session tracking
262
+
263
+ OPAI does not create a separate chat-history format. It records identifiers for the agents' own native conversations in `~/.config/opai/sessions.json`.
264
+
265
+ New session records include the requested launch model, effort, and resolved initial prompt. `null` represents Default. Older records without these fields remain usable and are treated as Default. These values describe only how OPAI launched the session; a user may change settings inside the agent afterward.
266
+
267
+ ### Claude Code
268
+
269
+ OPAI assigns a UUID with Claude's native `--session-id` option, launches the exact ticket prompt, and saves the association only after Claude creates the corresponding native session. Resume uses Claude's native `--resume` option.
270
+
271
+ ### Codex
272
+
273
+ Codex does not currently expose an equivalent interactive launch option for assigning a session ID. OPAI compares native Codex session metadata before and after launch and records a session only when exactly one new session matches both:
274
+
275
+ - the original working directory; and
276
+ - the exact initial ticket prompt.
277
+
278
+ Resume uses Codex's native `resume` command. This capture depends on Codex's local JSONL metadata format and may need updating if that format changes.
279
+
280
+ ### Safety guarantees
281
+
282
+ - OPAI never invents a session ID.
283
+ - It never associates a ticket based only on the newest session timestamp.
284
+ - Multiple sessions for the same ticket are retained.
285
+ - Resume uses the session's original working directory.
286
+ - A missing native session or working directory produces an error instead of starting a new conversation.
287
+ - If automatic capture fails, **Find existing native session** searches for conversations whose first prompt exactly matches the ticket's current effective prompt template.
288
+
289
+ ## Cache and local data
290
+
291
+ OPAI stores local state below `~/.config/opai/`:
292
+
293
+ | Path | Purpose |
294
+ | --- | --- |
295
+ | `config.json` | OpenProject and agent settings |
296
+ | `token` | API token with owner-only permissions |
297
+ | `sessions.json` | Ticket-to-native-session associations |
298
+ | `cache/` | Assigned tickets, saved queries, and query results |
299
+ | `query-preferences.json` | Pinned and recently opened queries |
300
+ | `launch-preferences.json` | Per-agent model and effort defaults plus provider prompt defaults |
301
+ | `dashboard-history.json` | Daily local snapshots used by dashboard statistics |
302
+
303
+ Opening OPAI does not automatically call OpenProject while a valid cached list exists. The default cache lifetime is eight hours, so reopening it in another terminal reuses the same list. After expiry, the next access reloads that list. Refresh actions always fetch immediately.
304
+
305
+ Cache namespaces include the provider identity, URL, type mapping, and a one-way hash derived from the token. Cache files do not contain the token.
306
+
307
+ ## Read-only OpenProject access
308
+
309
+ OPAI sends only `GET` requests to OpenProject API v3. It reads:
310
+
311
+ - the authenticated user;
312
+ - assigned open work packages;
313
+ - individual work packages;
314
+ - saved-query definitions; and
315
+ - saved-query results.
316
+
317
+ It does not create, edit, delete, or change tickets or saved queries.
318
+
319
+ Launched agents run with their own configuration and permission rules. If Claude Code or Codex has write access through MCP, those capabilities belong to the agent and remain outside OPAI's read-only API integration. OPAI does not install or modify MCP servers.
320
+
321
+ ## Architecture
322
+
323
+ ```text
324
+ TicketProvider
325
+ -> OpenProjectProvider
326
+ -> normalized Ticket
327
+ -> interactive picker
328
+ -> Claude/Codex adapter
329
+ -> native session registry
330
+ ```
331
+
332
+ The `TicketProvider` contract handles listing and retrieving tickets, normalization, and construction of Implement or Fix prompts. UI, agent execution, caching, and session storage do not depend on OpenProject response shapes.
333
+
334
+ V1 intentionally includes one static provider implementation. It has no plugin loader, background service, database, tmux management, ticket modification, branch management, or pull-request automation.
335
+
336
+ ## Known limitations
337
+
338
+ - OpenProject is the only ticket provider included in V1.
339
+ - Bug and User Story type IDs must be configured for each OpenProject instance.
340
+ - Claude Code and Codex must already be installed and authenticated.
341
+ - Each agent needs its own OpenProject integration. A working Claude MCP setup does not imply that Codex has the same MCP server.
342
+ - Native session files must remain present for resume to work.
343
+ - Codex capture relies on locally stored native metadata because its interactive launcher does not provide an explicit session-ID option.
344
+ - After a failed automatic capture, recovery matches the current effective prompt; a one-session prompt edit must be saved as the ticket-type default before recovery can match it.
345
+ - Codex discovery depends on `codex debug models --bundled`; older CLIs fall back to configured IDs.
346
+ - Claude Code does not expose a supported complete model catalog, so exact version IDs must be configured when aliases are insufficient.
347
+
348
+ ## Troubleshooting
349
+
350
+ ### `Create ~/.config/opai/config.json from config.example.json`
351
+
352
+ Create the configuration file and replace every sample OpenProject value with the values from your instance.
353
+
354
+ ### `OpenProject API returned HTTP 401`
355
+
356
+ Delete `~/.config/opai/token`, run `opai`, and enter a valid API token. Also confirm that API access is enabled by your OpenProject administrator.
357
+
358
+ ### `Could not connect to OpenProject`
359
+
360
+ Check `openproject.url`, DNS, VPN access, and TLS. The URL should look like `https://openproject.example.com` without `/api/v3` appended.
361
+
362
+ ### A ticket has no Implement or Fix action
363
+
364
+ Check the work package's numeric type ID and update `bugTypeId` or `userStoryTypeId`. The visible type name alone is not used for action mapping.
365
+
366
+ ### No session was recorded
367
+
368
+ OPAI saves a session only after verifying a native ID. Reopen the ticket and select **Find existing native session**. If nothing is found, confirm that the agent created a native session with the exact initial prompt and that its local session files are still available.
369
+
370
+ ### Codex cannot retrieve the ticket
371
+
372
+ Configure OpenProject access for Codex separately. OPAI deliberately does not change global agent or MCP configuration.
373
+
374
+ ## Development
375
+
376
+ ```sh
377
+ npm ci
378
+ npm run typecheck
379
+ npm test
380
+ npm run build
381
+ ```
382
+
383
+ Tests mock OpenProject responses and child-process behavior. They do not require live OpenProject credentials and do not launch real coding agents.
384
+
385
+ ## Support and contributions
386
+
387
+ Use [GitHub Issues](https://github.com/MahmoudWael/opai/issues) for bug reports, setup problems, and focused feature proposals. When reporting a session-capture problem, include the agent name and version, operating environment, and the OPAI error message. Never include API tokens or private ticket contents.
388
+
389
+ ## License
390
+
391
+ [MIT](LICENSE) © 2026 Mahmoud Wael
@@ -0,0 +1,22 @@
1
+ {
2
+ "openproject": {
3
+ "url": "https://openproject.example.com",
4
+ "instanceId": "main",
5
+ "bugTypeId": 1,
6
+ "userStoryTypeId": 2,
7
+ "promptTemplates": {
8
+ "bug": "fix openproject bug {{id}}",
9
+ "userStory": "implement openproject user story {{id}}"
10
+ }
11
+ },
12
+ "cacheTtlHours": 8,
13
+ "defaultAgent": "claude",
14
+ "agents": {
15
+ "claude": "claude",
16
+ "codex": "codex"
17
+ },
18
+ "models": {
19
+ "claude": [],
20
+ "codex": []
21
+ }
22
+ }
@@ -0,0 +1,89 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { isModelId } from '../models.js';
3
+ export function availableEfforts(capabilities, model) {
4
+ const selected = model ? capabilities.models.find(item => item.id === model) : undefined;
5
+ if (selected?.efforts.length)
6
+ return [...selected.efforts];
7
+ return unique([...capabilities.efforts, ...capabilities.models.flatMap(item => item.efforts)]);
8
+ }
9
+ export function resolveEffort(effort, available) {
10
+ if (effort === null)
11
+ return null;
12
+ if (!available.includes(effort))
13
+ throw new Error(`Saved effort ${JSON.stringify(effort)} is not available for the selected model.`);
14
+ return effort;
15
+ }
16
+ const CLAUDE_ALIASES = ['sonnet', 'opus', 'haiku'];
17
+ const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
18
+ function unique(values) { return [...new Set(values)]; }
19
+ export function parseCodexModelCatalog(raw, configured = []) {
20
+ const value = JSON.parse(raw);
21
+ if (!value || typeof value !== 'object' || !Array.isArray(value.models))
22
+ throw new Error('Codex returned an invalid model catalog.');
23
+ const models = [];
24
+ for (const item of value.models) {
25
+ if (!item || typeof item !== 'object')
26
+ continue;
27
+ const model = item;
28
+ if (model.visibility !== 'list' || !isModelId(model.slug) || typeof model.display_name !== 'string')
29
+ continue;
30
+ const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
31
+ const efforts = levels.flatMap(level => level && typeof level === 'object' && typeof level.effort === 'string' ? [level.effort] : []);
32
+ models.push({
33
+ id: model.slug,
34
+ label: model.display_name,
35
+ ...(typeof model.default_reasoning_level === 'string' ? { defaultEffort: model.default_reasoning_level } : {}),
36
+ efforts: unique(efforts)
37
+ });
38
+ }
39
+ for (const id of configured) {
40
+ if (!isModelId(id))
41
+ throw new Error(`Invalid codex model ID: ${JSON.stringify(id)}.`);
42
+ if (!models.some(model => model.id === id))
43
+ models.push({ id, label: id, efforts: [] });
44
+ }
45
+ return models;
46
+ }
47
+ function optionBlock(help, flag) {
48
+ const start = help.indexOf(flag);
49
+ if (start < 0)
50
+ return '';
51
+ const end = help.indexOf('\n --', start + flag.length);
52
+ return help.slice(start, end < 0 ? undefined : end);
53
+ }
54
+ export function parseClaudeHelp(help, configured = []) {
55
+ const modelBlock = optionBlock(help, '--model <model>');
56
+ const aliasText = modelBlock.split("or a model's full name")[0] ?? '';
57
+ const discoveredAliases = [...aliasText.matchAll(/'([A-Za-z0-9._:/@-]+)'/g)].map(match => match[1]);
58
+ const ids = unique([...CLAUDE_ALIASES, ...discoveredAliases, ...configured]);
59
+ for (const id of ids)
60
+ if (!isModelId(id))
61
+ throw new Error(`Invalid claude model ID: ${JSON.stringify(id)}.`);
62
+ const effortBlock = optionBlock(help, '--effort <level>');
63
+ const efforts = CLAUDE_EFFORTS.filter(effort => new RegExp(`\\b${effort}\\b`).test(effortBlock));
64
+ return {
65
+ models: ids.map(id => ({ id, label: id[0].toUpperCase() + id.slice(1), efforts: [] })),
66
+ efforts: efforts.length ? efforts : [...CLAUDE_EFFORTS],
67
+ discovered: Boolean(modelBlock || effortBlock)
68
+ };
69
+ }
70
+ const readCommand = (executable, args) => new Promise((resolve, reject) => {
71
+ execFile(executable, args, { maxBuffer: 2 * 1024 * 1024 }, (error, stdout) => error ? reject(error) : resolve(stdout));
72
+ });
73
+ export async function discoverAgentCapabilities(agent, executable, configured = [], run = readCommand) {
74
+ if (agent === 'claude') {
75
+ try {
76
+ return parseClaudeHelp(await run(executable, ['--help']), configured);
77
+ }
78
+ catch {
79
+ return parseClaudeHelp('', configured);
80
+ }
81
+ }
82
+ try {
83
+ const models = parseCodexModelCatalog(await run(executable, ['debug', 'models', '--bundled']), configured);
84
+ return { models, efforts: [], discovered: true };
85
+ }
86
+ catch {
87
+ return { models: configured.map(id => ({ id, label: id, efforts: [] })), efforts: [], discovered: false };
88
+ }
89
+ }
@@ -0,0 +1,76 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const defaultRoot = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects');
8
+ async function projectFiles(root) {
9
+ let projects;
10
+ try {
11
+ projects = await readdir(root, { withFileTypes: true });
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ const groups = await Promise.all(projects.filter(item => item.isDirectory()).map(async (project) => {
17
+ const dir = join(root, project.name);
18
+ try {
19
+ return (await readdir(dir)).filter(name => name.endsWith('.jsonl')).map(name => join(dir, name));
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }));
25
+ return groups.flat();
26
+ }
27
+ export async function nativeClaudeSessionExists(id, root = defaultRoot()) {
28
+ if (!UUID.test(id))
29
+ return false;
30
+ return (await projectFiles(root)).some(path => path.endsWith(`/${id}.jsonl`));
31
+ }
32
+ async function firstPrompt(path) {
33
+ const stream = createReadStream(path, { encoding: 'utf8' });
34
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
35
+ try {
36
+ for await (const line of lines) {
37
+ let event;
38
+ try {
39
+ event = JSON.parse(line);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ if (event.type !== 'user')
45
+ continue;
46
+ const content = event.message?.content;
47
+ const prompt = typeof content === 'string' ? content : Array.isArray(content) ? content.find(part => part?.type === 'text' && typeof part.text === 'string')?.text : undefined;
48
+ if (typeof prompt !== 'string')
49
+ continue;
50
+ if (!event.sessionId || !UUID.test(event.sessionId) || !event.cwd)
51
+ return undefined;
52
+ const createdAt = event.timestamp && Number.isFinite(Date.parse(event.timestamp)) ? event.timestamp : (await stat(path)).mtime.toISOString();
53
+ return { prompt, sessionId: event.sessionId, cwd: event.cwd, createdAt };
54
+ }
55
+ }
56
+ finally {
57
+ lines.close();
58
+ stream.destroy();
59
+ }
60
+ return undefined;
61
+ }
62
+ export async function findClaudeTicketSessions(prompt, root = defaultRoot()) {
63
+ const files = await projectFiles(root);
64
+ const candidates = await Promise.all(files.map(async (path) => {
65
+ try {
66
+ const first = await firstPrompt(path);
67
+ return first?.prompt === prompt && path.endsWith(`/${first.sessionId}.jsonl`) ? { agent: 'claude', sessionId: first.sessionId, cwd: first.cwd, createdAt: first.createdAt } : undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }));
73
+ return candidates.filter((candidate) => candidate !== undefined);
74
+ }
75
+ export function claudeLaunch(executable, cwd, prompt, id, model = null, effort = null) { return { executable, cwd, args: ['--session-id', id, ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), prompt] }; }
76
+ export function claudeResume(executable, cwd, id) { return { executable, cwd, args: ['--resume', id] }; }
@@ -0,0 +1,68 @@
1
+ import { readdir, open } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ export function codexLaunch(executable, cwd, prompt, model = null, effort = null) { return { executable, cwd, args: [...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []), prompt] }; }
5
+ export function codexResume(executable, cwd, id) { return { executable, cwd, args: ['resume', id] }; }
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ async function filesBelow(dir) {
8
+ let entries;
9
+ try {
10
+ entries = await readdir(dir, { withFileTypes: true });
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ const nested = await Promise.all(entries.map(async (entry) => entry.isDirectory() ? filesBelow(join(dir, entry.name)) : entry.name.endsWith('.jsonl') ? [join(dir, entry.name)] : []));
16
+ return nested.flat();
17
+ }
18
+ async function metadata(path) {
19
+ const file = await open(path, 'r');
20
+ try {
21
+ const content = await file.readFile('utf8');
22
+ const lines = content.split('\n');
23
+ const first = JSON.parse(lines[0] ?? '{}');
24
+ if (first.type !== 'session_meta')
25
+ return undefined;
26
+ const id = first.payload?.id ?? first.payload?.session_id;
27
+ if (!id || !UUID.test(id) || !first.payload?.cwd)
28
+ return undefined;
29
+ const messages = lines.slice(1).map(line => { try {
30
+ return JSON.parse(line);
31
+ }
32
+ catch {
33
+ return undefined;
34
+ } });
35
+ const prompts = messages.filter(event => (event?.type === 'event_msg' && event.payload?.type === 'user_message') || (event?.type === 'response_item' && event.payload?.type === 'message' && event.payload.role === 'user')).map(event => event?.payload?.message ?? event?.payload?.content?.find(part => part.type === 'input_text')?.text).filter((value) => typeof value === 'string' && !value.startsWith('<environment_context>'));
36
+ return { id, cwd: first.payload.cwd, prompt: prompts[0], createdAt: first.payload.timestamp && Number.isFinite(Date.parse(first.payload.timestamp)) ? first.payload.timestamp : new Date().toISOString() };
37
+ }
38
+ finally {
39
+ await file.close();
40
+ }
41
+ }
42
+ export async function codexSessionFiles(root = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) {
43
+ const files = await filesBelow(root);
44
+ return new Map(files.map(path => [path, path]));
45
+ }
46
+ export async function identifyCodexSession(before, after, cwd, prompt) {
47
+ const candidates = await Promise.all([...after.keys()].filter(path => !before.has(path)).map(metadata));
48
+ const matches = candidates.filter(item => item?.cwd === cwd && item.prompt === prompt);
49
+ return matches.length === 1 ? matches[0].id : undefined;
50
+ }
51
+ export async function findCodexTicketSessions(prompt, root = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) {
52
+ const candidates = await Promise.all((await filesBelow(root)).map(async (path) => {
53
+ try {
54
+ const item = await metadata(path);
55
+ return item?.prompt === prompt && path.endsWith(`${item.id}.jsonl`) ? { agent: 'codex', sessionId: item.id, cwd: item.cwd, createdAt: item.createdAt } : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }));
61
+ return candidates.filter((item) => item !== undefined);
62
+ }
63
+ export async function nativeCodexSessionExists(id, root = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) {
64
+ if (!UUID.test(id))
65
+ return false;
66
+ const files = await filesBelow(root);
67
+ return files.some(path => path.endsWith(`${id}.jsonl`));
68
+ }