@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.
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'node:child_process';
2
+ export async function runAgent(spec, onRunning, spawnProcess = spawn) {
3
+ return new Promise((resolve, reject) => {
4
+ let child;
5
+ try {
6
+ child = spawnProcess(spec.executable, spec.args, { cwd: spec.cwd, stdio: 'inherit', shell: false });
7
+ }
8
+ catch (error) {
9
+ reject(error);
10
+ return;
11
+ }
12
+ // Ctrl+C reaches both processes in the foreground group. Let the agent handle
13
+ // its signal, while OPAI remains alive long enough to record the session.
14
+ const onSigint = () => { };
15
+ process.on('SIGINT', onSigint);
16
+ let pending = Promise.resolve();
17
+ const timer = onRunning ? setInterval(() => {
18
+ pending = pending.then(onRunning).catch(() => { });
19
+ }, 1000) : undefined;
20
+ const cleanup = () => { if (timer)
21
+ clearInterval(timer); process.off('SIGINT', onSigint); };
22
+ child.once('error', error => { cleanup(); reject(error); });
23
+ child.once('exit', (code, signal) => {
24
+ cleanup();
25
+ void pending.then(() => resolve(code ?? (signal === 'SIGINT' ? 130 : 1)));
26
+ });
27
+ });
28
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/back.js ADDED
@@ -0,0 +1,20 @@
1
+ export const BACK = Symbol('back');
2
+ export async function promptWithBack(prompt, input = process.stdin) {
3
+ const controller = new AbortController();
4
+ const onKeypress = (_value, key) => {
5
+ if (key?.name === 'escape')
6
+ controller.abort(BACK);
7
+ };
8
+ input.on('keypress', onKeypress);
9
+ try {
10
+ return await prompt(controller.signal);
11
+ }
12
+ catch (error) {
13
+ if (controller.signal.reason === BACK && error instanceof Error && error.name === 'AbortPromptError')
14
+ return BACK;
15
+ throw error;
16
+ }
17
+ finally {
18
+ input.off('keypress', onKeypress);
19
+ }
20
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { stat } from 'node:fs/promises';
4
+ import { input, password, select, search, Separator } from '@inquirer/prompts';
5
+ import { loadConfig } from './config.js';
6
+ import { OpenProjectProvider } from './providers/openproject.js';
7
+ import { ticketKey } from './providers/types.js';
8
+ import { ListCache, isSavedQueryList, isTicketList } from './list-cache.js';
9
+ import { loadApiToken, saveApiToken, tokenPath } from './token.js';
10
+ import { SessionStore, sessionTickets } from './sessions/store.js';
11
+ import { syncSessionTickets } from './sessions/sync.js';
12
+ import { QueryPreferencesStore, querySections } from './query-preferences.js';
13
+ import { claudeLaunch, claudeResume, findClaudeTicketSessions, nativeClaudeSessionExists } from './agents/claude.js';
14
+ import { codexLaunch, codexResume, codexSessionFiles, findCodexTicketSessions, identifyCodexSession, nativeCodexSessionExists } from './agents/codex.js';
15
+ import { runAgent } from './agents/run.js';
16
+ import { accent, agentClosedScreen, bold, centeredMenuChoices, goodbye, good, hint, listHighlight, muted, screen, selectionCursor, sessionRow, setIdleMascotMood, ticketRow, warning } from './ui.js';
17
+ import { statusBar } from './status.js';
18
+ import { BACK, promptWithBack } from './back.js';
19
+ import { DashboardHistoryStore, buildDashboard, renderDashboard } from './dashboard.js';
20
+ import { LaunchPreferenceStore } from './launch-preferences.js';
21
+ import { modelLabel, resolvePreferredModel } from './models.js';
22
+ import { availableEfforts, discoverAgentCapabilities, resolveEffort } from './agents/capabilities.js';
23
+ import { buildLaunchMenu, editablePromptConfig, launchDefaultsRow, launchDefaultsSummary, menuSectionHeader, promptDefaultsSummary } from './launch-menu.js';
24
+ import { MascotRotationStore } from './ui-state.js';
25
+ function aborted(error) { return error instanceof Error && error.name === 'ExitPromptError'; }
26
+ class ExitToTerminal extends Error {
27
+ }
28
+ const menuTheme = { icon: { cursor: selectionCursor() }, style: { highlight: accent } };
29
+ const listTheme = { ...menuTheme, style: { ...menuTheme.style, highlight: listHighlight, keysHelpTip: (keys) => `${keys.map(([key, action]) => `${key} ${action}`).join(' · ')} · Esc back` } };
30
+ async function directoryExists(path) { try {
31
+ return (await stat(path)).isDirectory();
32
+ }
33
+ catch {
34
+ return false;
35
+ } }
36
+ async function nativeExists(session) {
37
+ if (session.agent === 'codex')
38
+ return nativeCodexSessionExists(session.sessionId);
39
+ return nativeClaudeSessionExists(session.sessionId);
40
+ }
41
+ async function main() {
42
+ const config = await loadConfig();
43
+ setIdleMascotMood(await new MascotRotationStore().next());
44
+ let apiToken = await loadApiToken();
45
+ if (!apiToken) {
46
+ if (!process.stdin.isTTY)
47
+ throw new Error(`No OpenProject API token found. Run opai in a terminal to save one, or set OPENPROJECT_API_TOKEN.`);
48
+ screen('Connect OpenProject', 'One-time setup · token is saved locally with owner-only access');
49
+ apiToken = await password({ message: 'OpenProject API token', mask: '*' });
50
+ if (!apiToken.trim())
51
+ throw new Error('API token cannot be empty.');
52
+ await saveApiToken(apiToken);
53
+ console.log(`Saved token to ${tokenPath}.`);
54
+ }
55
+ const provider = new OpenProjectProvider(config.openproject, apiToken);
56
+ const store = new SessionStore();
57
+ const dashboardHistory = new DashboardHistoryStore();
58
+ const queryPreferences = new QueryPreferencesStore();
59
+ const launchPreferences = new LaunchPreferenceStore();
60
+ const namespace = createHash('sha256').update(JSON.stringify([provider.identity, config.openproject.url, config.openproject.bugTypeId, config.openproject.userStoryTypeId, apiToken])).digest('hex');
61
+ const cacheTtlMs = (config.cacheTtlHours ?? 8) * 60 * 60 * 1000;
62
+ const ticketCache = new ListCache(namespace, cacheTtlMs, isTicketList);
63
+ const queryCache = new ListCache(namespace, cacheTtlMs, isSavedQueryList);
64
+ async function cachedList(cache, key, load, label, noun, refresh = false) {
65
+ let fetched = false;
66
+ const value = await cache.get(key, () => {
67
+ fetched = true;
68
+ return statusBar.run(`Loading ${label}`, load, items => `${items.length} ${noun} loaded`);
69
+ }, refresh);
70
+ if (!fetched)
71
+ statusBar.set('cached', `${value.length} ${noun} from cache`);
72
+ return value;
73
+ }
74
+ async function cachedTickets(key, load, label, refresh = false, includeMissing = false) {
75
+ let unavailable = [];
76
+ const tickets = await cachedList(ticketCache, key, async () => {
77
+ const fresh = await load();
78
+ unavailable = (await syncSessionTickets(store, provider, fresh, includeMissing)).unavailable;
79
+ if (key === 'mine')
80
+ await dashboardHistory.record(provider.identity, fresh);
81
+ return fresh;
82
+ }, label, 'tickets', refresh);
83
+ if (unavailable.length)
84
+ console.warn(`Could not refresh saved status for ticket${unavailable.length === 1 ? '' : 's'} ${unavailable.map(id => `#${id}`).join(', ')}.`);
85
+ return tickets;
86
+ }
87
+ const cwd = config.cwd ?? process.cwd();
88
+ if (!await directoryExists(cwd))
89
+ throw new Error(`Working directory does not exist: ${cwd}`);
90
+ const executables = { claude: config.agents?.claude ?? 'claude', codex: config.agents?.codex ?? 'codex' };
91
+ const configuredModels = { claude: config.models?.claude ?? [], codex: config.models?.codex ?? [] };
92
+ const capabilityCache = {};
93
+ const agentName = (agent) => agent === 'claude' ? 'Claude Code' : 'Codex';
94
+ const promptKind = (ticket) => ticket.type === 'Bug' ? 'bug' : 'userStory';
95
+ const ticketAction = (ticket) => ticket.type === 'Bug' ? 'fix' : 'implement';
96
+ const effortLabel = (effort) => effort === null ? 'Default' : effort[0].toUpperCase() + effort.slice(1);
97
+ async function afterAgentClosed(detail, failed = false) {
98
+ agentClosedScreen(detail, failed ? 'error' : 'success');
99
+ const [returnLabel, exitLabel, message] = centeredMenuChoices(['↩ Return to OPAI', '× Exit to terminal', 'What next?']);
100
+ const choice = await promptWithBack(signal => select({
101
+ message: message,
102
+ default: 'return',
103
+ theme: menuTheme,
104
+ choices: [
105
+ { name: returnLabel, value: 'return' },
106
+ { name: exitLabel, value: 'exit' }
107
+ ]
108
+ }, { signal }));
109
+ if (choice === 'exit')
110
+ throw new ExitToTerminal();
111
+ }
112
+ async function capabilities(agent) {
113
+ capabilityCache[agent] ??= discoverAgentCapabilities(agent, executables[agent], configuredModels[agent]);
114
+ return capabilityCache[agent];
115
+ }
116
+ function modelDisplay(model, caps) {
117
+ if (model === null)
118
+ return 'Default';
119
+ const found = caps?.models.find(item => item.id === model);
120
+ return found && found.label !== found.id ? `${found.label} · ${found.id}` : found?.label ?? model;
121
+ }
122
+ async function selectModel(agent, current, caps) {
123
+ return promptWithBack(signal => select({ message: `${agentName(agent)} model`, pageSize: 12, theme: menuTheme, default: current, choices: [
124
+ { name: `${muted('◇ Default')}${current === null ? good(' · current') : ''}`, value: null },
125
+ ...caps.models.map(model => ({ name: `${accent('◈')} ${bold(modelDisplay(model.id, caps))}${model.id === current ? good(' · current') : ''}`, value: model.id }))
126
+ ] }, { signal }));
127
+ }
128
+ async function selectEffort(agent, current, model, caps) {
129
+ const efforts = availableEfforts(caps, model);
130
+ const agentDefault = model === null ? undefined : caps.models.find(item => item.id === model)?.defaultEffort;
131
+ return promptWithBack(signal => select({ message: `${agentName(agent)} effort`, pageSize: 10, theme: menuTheme, default: current, choices: [
132
+ { name: `${muted('◇ Default')}${agentDefault ? muted(` · agent uses ${effortLabel(agentDefault)}`) : ''}${current === null ? good(' · current') : ''}`, value: null },
133
+ ...efforts.map(effort => ({ name: `${warning('✦')} ${bold(effortLabel(effort))}${effort === current ? good(' · current') : ''}`, value: effort }))
134
+ ] }, { signal }));
135
+ }
136
+ async function launchOptions(ticket, agent) {
137
+ const caps = await capabilities(agent);
138
+ const preferences = await launchPreferences.all();
139
+ const kind = promptKind(ticket);
140
+ let model = preferences.agents[agent].model;
141
+ let effort = preferences.agents[agent].effort;
142
+ let template = preferences.prompts[provider.identity]?.[kind] ?? provider.promptTemplate(kind);
143
+ let focusedAction = 'start';
144
+ while (true) {
145
+ let preview;
146
+ try {
147
+ preview = provider.prompt(ticket, ticketAction(ticket), template);
148
+ }
149
+ catch (error) {
150
+ preview = error instanceof Error ? error.message : String(error);
151
+ }
152
+ const capsEfforts = availableEfforts(caps, model);
153
+ const modelAvailable = model === null || caps.models.some(item => item.id === model);
154
+ const effortAvailable = effort === null || capsEfforts.includes(effort);
155
+ screen(`Start ${agentName(agent)} · #${ticket.id}`, `${ticket.typeLabel} · ${ticket.title}`, agent);
156
+ console.log(` ${muted(`Tip: {{id}} becomes ${ticket.id}`)}`);
157
+ console.log(` ${muted(`Will send: ${preview}`)}\n`);
158
+ const menu = buildLaunchMenu(modelDisplay(model, caps), effortLabel(effort), template, focusedAction, { model: modelAvailable, effort: effortAvailable });
159
+ const choice = await promptWithBack(signal => select({ message: 'Launch options', pageSize: 8, theme: menuTheme, ...menu }, { signal }));
160
+ if (choice === BACK)
161
+ return undefined;
162
+ focusedAction = choice;
163
+ if (choice === 'model') {
164
+ const selected = await selectModel(agent, model, caps);
165
+ if (selected !== BACK) {
166
+ model = selected;
167
+ const validEfforts = availableEfforts(caps, model);
168
+ if (effort !== null && !validEfforts.includes(effort)) {
169
+ effort = null;
170
+ statusBar.set('cached', 'Effort returned to Default for the selected model');
171
+ }
172
+ }
173
+ }
174
+ else if (choice === 'effort') {
175
+ const selected = await selectEffort(agent, effort, model, caps);
176
+ if (selected !== BACK)
177
+ effort = selected;
178
+ }
179
+ else if (choice === 'prompt') {
180
+ const selected = await promptWithBack(signal => input(editablePromptConfig('Initial prompt template', template), { signal }));
181
+ if (selected !== BACK)
182
+ template = selected;
183
+ }
184
+ else if (choice === 'reset-prompt') {
185
+ template = provider.promptTemplate(kind);
186
+ statusBar.set('success', `${ticket.typeLabel} prompt restored to configured default`);
187
+ }
188
+ else if (choice === 'save') {
189
+ resolvePreferredModel(agent, model, caps.models.map(item => item.id));
190
+ resolveEffort(effort, availableEfforts(caps, model));
191
+ provider.prompt(ticket, ticketAction(ticket), template);
192
+ await launchPreferences.setAgent(agent, { model, effort });
193
+ await launchPreferences.setPrompt(provider.identity, kind, template);
194
+ statusBar.set('success', `${agentName(agent)} and ${ticket.typeLabel} defaults saved`);
195
+ }
196
+ else {
197
+ const resolvedModel = resolvePreferredModel(agent, model, caps.models.map(item => item.id));
198
+ const resolvedEffort = resolveEffort(effort, availableEfforts(caps, model));
199
+ provider.prompt(ticket, ticketAction(ticket), template);
200
+ return { model: resolvedModel, effort: resolvedEffort, template };
201
+ }
202
+ }
203
+ }
204
+ async function launch(ticket, agent, selection) {
205
+ const prompt = provider.prompt(ticket, ticketAction(ticket), selection.template);
206
+ const { model, effort } = selection;
207
+ const id = agent === 'claude' ? randomUUID() : undefined;
208
+ const before = agent === 'codex' ? await codexSessionFiles() : undefined;
209
+ const started = new Date().toISOString();
210
+ const spec = agent === 'claude' ? claudeLaunch(executables.claude, cwd, prompt, id, model, effort) : codexLaunch(executables.codex, cwd, prompt, model, effort);
211
+ let savedId;
212
+ const record = async () => {
213
+ if (savedId)
214
+ return;
215
+ const sessionId = agent === 'claude' ? id : await identifyCodexSession(before, await codexSessionFiles(), cwd, prompt);
216
+ if (!sessionId || !await nativeExists({ agent, sessionId, cwd, model, effort, initialPrompt: prompt, createdAt: started }))
217
+ return;
218
+ await store.add(ticketKey(ticket), { agent, sessionId, cwd, model, effort, initialPrompt: prompt, createdAt: started, lastUsedAt: new Date().toISOString(), ticket });
219
+ savedId = sessionId;
220
+ };
221
+ const code = await runAgent(spec, record);
222
+ await record();
223
+ const exitedUnexpectedly = code !== 0 && code !== 130;
224
+ const saved = savedId
225
+ ? `${agentName(agent)} session saved for #${ticket.id}`
226
+ : `No verified ${agentName(agent)} session ID was found`;
227
+ const detail = exitedUnexpectedly ? `${saved} · exited with code ${code}` : saved;
228
+ await afterAgentClosed(detail, exitedUnexpectedly || !savedId);
229
+ }
230
+ async function agentDefaultsMenu(agent) {
231
+ const caps = await capabilities(agent);
232
+ let focusedOption = 'model';
233
+ while (true) {
234
+ const current = (await launchPreferences.all()).agents[agent];
235
+ screen(`${agentName(agent)} defaults`, caps.discovered ? 'Models and effort read from the installed CLI' : 'Using configured models because local discovery was unavailable', agent);
236
+ const choice = await promptWithBack(signal => select({ message: 'Choose a default', pageSize: 5, theme: menuTheme, default: focusedOption, choices: [
237
+ { name: `◈ Model · ${accent(bold(modelDisplay(current.model, caps)))}`, value: 'model' },
238
+ { name: `✦ Effort · ${warning(bold(effortLabel(current.effort)))}`, value: 'effort' }
239
+ ] }, { signal }));
240
+ if (choice === BACK)
241
+ return;
242
+ focusedOption = choice;
243
+ let next = current;
244
+ if (choice === 'model') {
245
+ const selected = await selectModel(agent, current.model, caps);
246
+ if (selected === BACK)
247
+ continue;
248
+ const efforts = availableEfforts(caps, selected);
249
+ next = { model: selected, effort: current.effort !== null && !efforts.includes(current.effort) ? null : current.effort };
250
+ }
251
+ else {
252
+ const selected = await selectEffort(agent, current.effort, current.model, caps);
253
+ if (selected === BACK)
254
+ continue;
255
+ next = { ...current, effort: selected };
256
+ }
257
+ await launchPreferences.setAgent(agent, next);
258
+ statusBar.set('success', `${agentName(agent)} defaults saved`);
259
+ }
260
+ }
261
+ async function promptDefaultsMenu(kind) {
262
+ while (true) {
263
+ const preferences = await launchPreferences.all();
264
+ const configured = provider.promptTemplate(kind);
265
+ const current = preferences.prompts[provider.identity]?.[kind] ?? configured;
266
+ const label = kind === 'bug' ? 'Bug' : 'User Story';
267
+ screen(`${label} prompt default`, current);
268
+ const choice = await promptWithBack(signal => select({ message: 'Prompt template', pageSize: 5, theme: menuTheme, choices: [
269
+ { name: 'Edit prompt template', value: 'edit' },
270
+ { name: 'Use configured provider template', value: 'reset' }
271
+ ] }, { signal }));
272
+ if (choice === BACK)
273
+ return;
274
+ if (choice === 'reset')
275
+ await launchPreferences.setPrompt(provider.identity, kind, null);
276
+ else {
277
+ const selected = await promptWithBack(signal => input(editablePromptConfig(`${label} prompt template`, current), { signal }));
278
+ if (selected === BACK)
279
+ continue;
280
+ await launchPreferences.setPrompt(provider.identity, kind, selected);
281
+ }
282
+ statusBar.set('success', `${label} prompt default saved`);
283
+ }
284
+ }
285
+ async function launchDefaultsMenu() {
286
+ while (true) {
287
+ const preferences = await launchPreferences.all();
288
+ const promptPreferences = preferences.prompts[provider.identity];
289
+ screen('Launch defaults', 'Preselected for new sessions · every launch can override them');
290
+ const choice = await promptWithBack(signal => select({ message: 'Choose a default', pageSize: 9, theme: menuTheme, choices: [
291
+ new Separator(accent(bold(menuSectionHeader('Agent defaults')))),
292
+ new Separator(''),
293
+ { name: launchDefaultsRow('Claude Code', launchDefaultsSummary(modelLabel(preferences.agents.claude.model), effortLabel(preferences.agents.claude.effort))), value: 'claude' },
294
+ { name: launchDefaultsRow('Codex', launchDefaultsSummary(modelLabel(preferences.agents.codex.model), effortLabel(preferences.agents.codex.effort))), value: 'codex' },
295
+ new Separator(''),
296
+ new Separator(accent(bold(menuSectionHeader('Prompt defaults')))),
297
+ new Separator(''),
298
+ { name: launchDefaultsRow('Bug prompt', promptDefaultsSummary(promptPreferences?.bug !== undefined)), value: 'bug' },
299
+ { name: launchDefaultsRow('User Story prompt', promptDefaultsSummary(promptPreferences?.userStory !== undefined)), value: 'userStory' }
300
+ ] }, { signal }));
301
+ if (choice === BACK)
302
+ return;
303
+ if (choice === 'claude' || choice === 'codex')
304
+ await agentDefaultsMenu(choice);
305
+ else
306
+ await promptDefaultsMenu(choice);
307
+ }
308
+ }
309
+ async function recover(ticket) {
310
+ const kind = promptKind(ticket);
311
+ const preferences = await launchPreferences.all();
312
+ const template = preferences.prompts[provider.identity]?.[kind] ?? provider.promptTemplate(kind);
313
+ const prompt = provider.prompt(ticket, ticketAction(ticket), template);
314
+ screen(`#${ticket.id} Find native session`, 'Sessions whose first user prompt exactly matches this ticket');
315
+ const candidates = [...await findClaudeTicketSessions(prompt), ...await findCodexTicketSessions(prompt)];
316
+ const available = (await Promise.all(candidates.map(async (candidate) => await directoryExists(candidate.cwd) ? candidate : undefined)))
317
+ .filter((candidate) => candidate !== undefined);
318
+ const existing = await store.list(ticketKey(ticket));
319
+ const matches = available.filter(candidate => !existing.some(session => session.agent === candidate.agent && session.sessionId === candidate.sessionId));
320
+ if (!matches.length) {
321
+ console.log('No unrecorded native session with this exact ticket prompt was found.');
322
+ return;
323
+ }
324
+ const index = await promptWithBack(signal => select({ message: 'Record a session for this ticket', pageSize: 12, theme: menuTheme, choices: matches.map((candidate, index) => ({ name: `${candidate.agent === 'claude' ? 'Claude Code' : 'Codex'} · ${new Date(candidate.createdAt).toLocaleString()} · ${candidate.cwd}`, value: index })) }, { signal }));
325
+ if (index === BACK)
326
+ return;
327
+ const candidate = matches[index];
328
+ if (!await directoryExists(candidate.cwd) || !await nativeExists(candidate)) {
329
+ console.log('Native session or working directory is no longer available.');
330
+ return;
331
+ }
332
+ await store.add(ticketKey(ticket), { ...candidate, lastUsedAt: candidate.createdAt, ticket });
333
+ console.log(`Recorded ${candidate.agent} session ${candidate.sessionId} for #${ticket.id}.`);
334
+ }
335
+ async function resumeForKey(key, title, confirmSingle = false) {
336
+ const sessions = await store.list(key);
337
+ if (!sessions.length) {
338
+ console.log('No session recorded for this ticket.');
339
+ return false;
340
+ }
341
+ if (sessions.length > 1 || confirmSingle)
342
+ screen(title, 'Choose a saved conversation', 'resume');
343
+ const index = sessions.length === 1 && !confirmSingle ? 0 : await promptWithBack(signal => select({ message: 'Resume session', pageSize: 12, theme: menuTheme, choices: sessions.map((s, index) => ({ name: `${s.agent === 'claude' ? 'Claude Code' : 'Codex'} · ${new Date(s.lastUsedAt ?? s.createdAt).toLocaleString()} · ${s.cwd}`, value: index })) }, { signal }));
344
+ const session = index === BACK ? undefined : sessions[index];
345
+ if (!session)
346
+ return false;
347
+ if (!await directoryExists(session.cwd)) {
348
+ console.log(`Working directory no longer exists: ${session.cwd}`);
349
+ return false;
350
+ }
351
+ if (!await nativeExists(session)) {
352
+ console.log(`Native ${session.agent} session ${session.sessionId} is missing or inaccessible.`);
353
+ return false;
354
+ }
355
+ screen(`Resume ${session.agent === 'claude' ? 'Claude Code' : 'Codex'}`, title, 'resume');
356
+ const spec = session.agent === 'claude' ? claudeResume(executables.claude, session.cwd, session.sessionId) : codexResume(executables.codex, session.cwd, session.sessionId);
357
+ const code = await runAgent(spec);
358
+ const exitedUnexpectedly = code !== 0 && code !== 130;
359
+ if (!exitedUnexpectedly)
360
+ await store.touch(key, session.sessionId);
361
+ const label = agentName(session.agent);
362
+ const detail = exitedUnexpectedly
363
+ ? `${label} exited with code ${code} · saved session kept`
364
+ : `${label} session is ready to resume later`;
365
+ await afterAgentClosed(detail, exitedUnexpectedly);
366
+ return true;
367
+ }
368
+ async function ticketMenu(ticket) {
369
+ while (true) {
370
+ screen(`#${ticket.id} ${ticket.title}`, `${ticket.typeLabel} · ${ticket.status} · Priority: ${ticket.priority?.name ?? 'unavailable'}${ticket.url ? ` · ${ticket.url}` : ''}`);
371
+ const sessions = await store.list(ticketKey(ticket));
372
+ const action = ticket.type === 'Bug' ? 'Fix' : 'Implement';
373
+ const choices = ticket.type === 'Unsupported' ? [] : [
374
+ { name: `${action} with Claude Code`, value: 'claude', description: 'Review launch options, then start a native Claude session' },
375
+ { name: `${action} with Codex`, value: 'codex', description: 'Review launch options, then start a native Codex session' }
376
+ ];
377
+ if (ticket.type === 'Unsupported')
378
+ console.log(` ${warning('No action mapped for this ticket type.')} ${muted('Set its type ID in config.json to enable it.')}\n`);
379
+ console.log(` ${muted('↑↓ move · Enter select · Esc back · Ctrl+C exit')}\n`);
380
+ const choice = await promptWithBack(signal => select({ message: 'Choose an action', pageSize: 8, theme: menuTheme, choices: [...choices, ...(sessions.length ? [{ name: `Resume saved session${sessions.length > 1 ? `s (${sessions.length})` : ''}`, value: 'resume' }] : []), ...(ticket.type !== 'Unsupported' ? [{ name: 'Find existing native session', value: 'recover' }] : [])] }, { signal }));
381
+ if (choice === BACK)
382
+ return;
383
+ try {
384
+ if (choice === 'resume')
385
+ await resumeForKey(ticketKey(ticket), `#${ticket.id} ${ticket.title}`);
386
+ else if (choice === 'recover')
387
+ await recover(ticket);
388
+ else {
389
+ const agent = choice;
390
+ const options = await launchOptions(ticket, agent);
391
+ if (options)
392
+ await launch(ticket, agent, options);
393
+ }
394
+ }
395
+ catch (error) {
396
+ if (error instanceof ExitToTerminal)
397
+ throw error;
398
+ console.error(error instanceof Error ? error.message : String(error));
399
+ }
400
+ }
401
+ }
402
+ const args = process.argv.slice(2);
403
+ if (args.length && args[0] !== 'mine' && args[0] !== 'show' && args[0] !== 'resume')
404
+ throw new Error('Usage: opai [mine | show <id> | resume <id>]');
405
+ if (args[0] === 'show' || args[0] === 'resume') {
406
+ if (!args[1])
407
+ throw new Error('Ticket ID required.');
408
+ if (args[0] === 'resume') {
409
+ if (!await resumeForKey(`${provider.identity}:${args[1]}`, `Ticket #${args[1]}`, true))
410
+ return;
411
+ }
412
+ else {
413
+ const ticket = await statusBar.run(`Loading ticket #${args[1]}`, () => provider.get(args[1]), item => `Ticket #${item.id} loaded`);
414
+ await ticketMenu(ticket);
415
+ return;
416
+ }
417
+ }
418
+ async function browseTickets(key, label, load) {
419
+ const tickets = await cachedTickets(key, load, label);
420
+ while (true) {
421
+ screen(label, `${tickets.length} ticket${tickets.length === 1 ? '' : 's'} · auto refresh after ${config.cacheTtlHours ?? 8}h`);
422
+ if (!tickets.length)
423
+ console.log(` ${muted('No tickets in this list.')}\n`);
424
+ console.log(` ${hint()}\n`);
425
+ const selected = await promptWithBack(signal => search({ message: 'Search by ID or title', pageSize: 12, theme: listTheme, source: async (term) => {
426
+ const filtered = tickets.filter(ticket => `${ticket.id} ${ticket.title} ${ticket.typeLabel} ${ticket.priority?.name ?? ''}`.toLowerCase().includes((term ?? '').toLowerCase()));
427
+ return filtered.map(ticket => ({ name: ticketRow(ticket), value: ticket, short: `#${ticket.id} ${ticket.title}` }));
428
+ } }, { signal }));
429
+ if (selected === BACK)
430
+ return;
431
+ await ticketMenu(selected);
432
+ }
433
+ }
434
+ async function queryMenu(query) {
435
+ const key = `query:${query.id}`;
436
+ const load = () => provider.listQueryTickets(query.id);
437
+ while (true) {
438
+ screen(query.name, 'Saved query · ticket results load on first open');
439
+ const pinned = Boolean((await queryPreferences.all())[`${provider.identity}:${query.id}`]?.pinned);
440
+ const choice = await promptWithBack(signal => select({ message: 'What would you like to do?', theme: menuTheme, choices: [
441
+ { name: 'Browse tickets', value: 'browse' },
442
+ { name: '↻ Refresh query results', value: 'refresh' },
443
+ { name: pinned ? '★ Unpin query' : '☆ Pin query', value: 'pin' }
444
+ ] }, { signal }));
445
+ if (choice === BACK)
446
+ return;
447
+ try {
448
+ if (choice === 'pin') {
449
+ const next = await queryPreferences.togglePin(provider.identity, query.id);
450
+ statusBar.set('success', next ? 'Query pinned' : 'Query unpinned');
451
+ }
452
+ else if (choice === 'refresh') {
453
+ await cachedTickets(key, load, query.name, true);
454
+ }
455
+ else
456
+ await browseTickets(key, query.name, load);
457
+ }
458
+ catch (error) {
459
+ if (error instanceof ExitToTerminal)
460
+ throw error;
461
+ console.error(error instanceof Error ? error.message : String(error));
462
+ }
463
+ }
464
+ }
465
+ async function savedQueries() {
466
+ const queries = await cachedList(queryCache, 'saved', () => provider.listSavedQueries(), 'saved queries', 'queries');
467
+ while (true) {
468
+ screen('Saved queries', `${queries.length} quer${queries.length === 1 ? 'y' : 'ies'} · auto refresh after ${config.cacheTtlHours ?? 8}h`);
469
+ console.log(` ${muted('Type to search · ↑↓ move · Enter select · Esc back')}\n`);
470
+ const preferences = await queryPreferences.all();
471
+ const choice = await promptWithBack(signal => search({ message: 'Search saved queries', pageSize: 15, theme: listTheme, source: async (term) => {
472
+ const filtered = queries.filter(query => `${query.id} ${query.name}`.toLowerCase().includes((term ?? '').toLowerCase()));
473
+ return querySections(filtered, preferences, provider.identity).flatMap(section => {
474
+ const heading = section.kind === 'pinned'
475
+ ? warning(bold('── ★ Pinned ─────────────────────'))
476
+ : section.kind === 'recent'
477
+ ? accent(bold('── ◷ Recently viewed ────────────'))
478
+ : bold('── All queries ─────────────────');
479
+ const marker = section.kind === 'pinned' ? '★' : section.kind === 'recent' ? '◷' : '▤';
480
+ return [
481
+ new Separator(heading),
482
+ ...section.queries.map(query => ({ name: `${marker} ${query.name}`, value: query, short: query.name }))
483
+ ];
484
+ });
485
+ } }, { signal }));
486
+ if (choice === BACK)
487
+ return;
488
+ const query = choice;
489
+ await queryPreferences.opened(provider.identity, query.id);
490
+ await queryMenu(query);
491
+ }
492
+ }
493
+ async function mySessions() {
494
+ while (true) {
495
+ const groups = sessionTickets(await store.all(), provider.identity);
496
+ screen('My sessions', `${groups.length} ticket${groups.length === 1 ? '' : 's'} with saved conversations · local only`);
497
+ if (!groups.length)
498
+ console.log(` ${muted('No saved sessions yet. Launch an agent from a ticket first.')}\n`);
499
+ console.log(` ${muted('Type a ticket ID or title · Esc back')}\n`);
500
+ const choice = await promptWithBack(signal => search({ message: 'Search saved sessions', pageSize: 12, theme: listTheme, source: async (term) => {
501
+ const filtered = groups.filter(group => `${group.id} ${group.title}`.toLowerCase().includes((term ?? '').toLowerCase()));
502
+ return filtered.map(group => ({ name: sessionRow(group), value: group, short: `#${group.id} ${group.title}` }));
503
+ } }, { signal }));
504
+ if (choice === BACK)
505
+ return;
506
+ const group = choice;
507
+ await resumeForKey(group.key, `#${group.id} ${group.title}`, true);
508
+ }
509
+ }
510
+ if (args[0] === 'mine') {
511
+ await browseTickets('mine', 'My tickets', () => provider.listAssigned());
512
+ return;
513
+ }
514
+ while (true) {
515
+ screen('Home', `Browse tickets or saved queries · cache expires after ${config.cacheTtlHours ?? 8}h`);
516
+ const cachedMine = await ticketCache.peek('mine');
517
+ if (cachedMine)
518
+ await dashboardHistory.record(provider.identity, cachedMine.value, new Date(cachedMine.fetchedAt));
519
+ const dashboard = buildDashboard(cachedMine?.value ?? [], cachedMine?.fetchedAt, await store.all(), provider.identity, await dashboardHistory.list(provider.identity));
520
+ console.log(`${renderDashboard(dashboard)}\n`);
521
+ const choice = await promptWithBack(signal => select({ message: 'Choose a view', theme: menuTheme, choices: [
522
+ { name: 'My tickets', value: 'mine' },
523
+ { name: 'Saved queries', value: 'queries' },
524
+ { name: 'My sessions', value: 'sessions' },
525
+ { name: 'Launch defaults', value: 'launch-defaults' },
526
+ { name: '↻ Refresh my tickets', value: 'refresh-mine' },
527
+ { name: '↻ Refresh saved queries', value: 'refresh-queries' },
528
+ { name: '× Exit OPAI', value: 'exit' }
529
+ ] }, { signal }));
530
+ if (choice === 'exit' || choice === BACK)
531
+ return;
532
+ try {
533
+ if (choice === 'mine')
534
+ await browseTickets('mine', 'My tickets', () => provider.listAssigned());
535
+ else if (choice === 'queries')
536
+ await savedQueries();
537
+ else if (choice === 'sessions')
538
+ await mySessions();
539
+ else if (choice === 'launch-defaults')
540
+ await launchDefaultsMenu();
541
+ else if (choice === 'refresh-mine') {
542
+ await cachedTickets('mine', () => provider.listAssigned(), 'my tickets', true, true);
543
+ }
544
+ else {
545
+ await cachedList(queryCache, 'saved', () => provider.listSavedQueries(), 'saved queries', 'queries', true);
546
+ }
547
+ }
548
+ catch (error) {
549
+ if (error instanceof ExitToTerminal)
550
+ throw error;
551
+ console.error(error instanceof Error ? error.message : String(error));
552
+ }
553
+ }
554
+ }
555
+ main().then(goodbye).catch(error => {
556
+ if (aborted(error) || error instanceof ExitToTerminal)
557
+ goodbye();
558
+ else {
559
+ console.error(error instanceof Error ? error.message : String(error));
560
+ process.exitCode = 1;
561
+ }
562
+ });
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { parseConfiguredModels } from './models.js';
5
+ export const configDir = join(homedir(), '.config', 'opai');
6
+ export function parsePromptTemplates(value) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (!value || typeof value !== 'object' || Array.isArray(value))
10
+ throw new Error('openproject.promptTemplates must be an object.');
11
+ const source = value;
12
+ const result = {};
13
+ for (const kind of ['bug', 'userStory']) {
14
+ const template = source[kind];
15
+ if (template === undefined)
16
+ continue;
17
+ if (typeof template !== 'string' || !template.includes('{{id}}'))
18
+ throw new Error(`openproject.promptTemplates.${kind} must contain {{id}}.`);
19
+ result[kind] = template;
20
+ }
21
+ return result;
22
+ }
23
+ export async function loadConfig() {
24
+ let raw;
25
+ try {
26
+ raw = JSON.parse(await readFile(join(configDir, 'config.json'), 'utf8'));
27
+ }
28
+ catch {
29
+ throw new Error(`Create ${join(configDir, 'config.json')} from config.example.json.`);
30
+ }
31
+ const c = raw;
32
+ if (!c.openproject?.url || !c.openproject.instanceId || !Number.isInteger(c.openproject.bugTypeId) || !Number.isInteger(c.openproject.userStoryTypeId))
33
+ throw new Error('Config needs openproject.url, instanceId, bugTypeId, and userStoryTypeId. See config.example.json.');
34
+ if (!/^https?:\/\//.test(c.openproject.url))
35
+ throw new Error('openproject.url must be an HTTP(S) URL.');
36
+ if (new URL(c.openproject.url).hostname.endsWith('.example.com'))
37
+ throw new Error(`Replace the sample openproject.url in ${join(configDir, 'config.json')} with your real OpenProject URL.`);
38
+ if (!/^[a-zA-Z0-9_-]+$/.test(c.openproject.instanceId))
39
+ throw new Error('openproject.instanceId must contain only letters, digits, _ or -.');
40
+ if (c.defaultAgent && !['claude', 'codex'].includes(c.defaultAgent))
41
+ throw new Error('defaultAgent must be claude or codex.');
42
+ if (c.cacheTtlHours !== undefined && (typeof c.cacheTtlHours !== 'number' || !Number.isFinite(c.cacheTtlHours) || c.cacheTtlHours <= 0 || c.cacheTtlHours > 168))
43
+ throw new Error('cacheTtlHours must be greater than 0 and at most 168.');
44
+ return { ...c, openproject: { ...c.openproject, promptTemplates: parsePromptTemplates(c.openproject.promptTemplates) }, models: parseConfiguredModels(c.models), cwd: c.cwd ? resolve(c.cwd) : undefined };
45
+ }