overlord-cli 0.2606070914.0 → 0.2606081020.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.
@@ -234,7 +234,10 @@ async function uploadToSignedUrl(uploadUrl, bytes, contentType, timeoutMs = DEFA
234
234
  // ---------------------------------------------------------------------------
235
235
 
236
236
  function getSessionFilePath() {
237
- return path.join(os.tmpdir(), `.overlord-session-${Buffer.from(process.cwd()).toString('base64url')}`);
237
+ return path.join(
238
+ os.tmpdir(),
239
+ `.overlord-session-${Buffer.from(process.cwd()).toString('base64url')}`
240
+ );
238
241
  }
239
242
 
240
243
  function persistSession(sessionKey, ticketId) {
@@ -244,7 +247,9 @@ function persistSession(sessionKey, ticketId) {
244
247
  JSON.stringify({ sessionKey, ticketId, ts: Date.now() }),
245
248
  'utf8'
246
249
  );
247
- } catch { /* best-effort */ }
250
+ } catch {
251
+ /* best-effort */
252
+ }
248
253
  }
249
254
 
250
255
  function readPersistedSession() {
@@ -252,7 +257,9 @@ function readPersistedSession() {
252
257
  const raw = fs.readFileSync(getSessionFilePath(), 'utf8');
253
258
  const data = JSON.parse(raw);
254
259
  if (data.sessionKey && data.ticketId) return data;
255
- } catch { /* missing or corrupt — ignore */ }
260
+ } catch {
261
+ /* missing or corrupt — ignore */
262
+ }
256
263
  return null;
257
264
  }
258
265
 
@@ -274,6 +281,161 @@ function resolveSessionFlags(flags) {
274
281
  return { sessionKey, ticketId };
275
282
  }
276
283
 
284
+ // ---------------------------------------------------------------------------
285
+ // Pending ticket drafts — local durability before DB confirmation
286
+ //
287
+ // New tickets/objectives are written to the server over the network. On a
288
+ // flaky connection a `create`/`prompt`/`add-objectives`/`record-work` call can
289
+ // fail after the user has already composed real text, and that text is lost.
290
+ // To prevent that, we persist the request body to a durable local file BEFORE
291
+ // the network call and only delete it once the server confirms the write
292
+ // succeeded. Anything left behind can be inspected, retried, or cleared with
293
+ // `ovld protocol pending-tickets`.
294
+ // ---------------------------------------------------------------------------
295
+
296
+ function getPendingTicketsDir() {
297
+ const override = process.env.OVERLORD_PENDING_TICKETS_DIR?.trim();
298
+ if (override) return override;
299
+ return path.join(os.homedir(), '.overlord', 'pending-tickets');
300
+ }
301
+
302
+ function getPendingTicketFilePath(id) {
303
+ return path.join(getPendingTicketsDir(), `${id}.json`);
304
+ }
305
+
306
+ function generatePendingTicketId() {
307
+ // Timestamp prefix keeps drafts naturally sortable; random suffix avoids
308
+ // collisions when several drafts are created within the same millisecond.
309
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
310
+ }
311
+
312
+ /** Pull human-readable objective text out of a request body for display. */
313
+ function describePendingObjectives(body) {
314
+ const objectives = Array.isArray(body?.objectives)
315
+ ? body.objectives
316
+ : typeof body?.objective === 'string'
317
+ ? [body.objective]
318
+ : [];
319
+ return objectives
320
+ .map(objective => (typeof objective === 'string' ? objective : objective?.objective))
321
+ .filter(value => typeof value === 'string' && value.trim().length > 0);
322
+ }
323
+
324
+ function truncateForDisplay(value, max = 200) {
325
+ const text = String(value ?? '');
326
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
327
+ }
328
+
329
+ /**
330
+ * Persist the request body for a new ticket/objective before sending it.
331
+ * Best-effort: if the local filesystem is not writable we return null and let
332
+ * the network call proceed unguarded rather than blocking the user's write.
333
+ * @returns {{ id: string, filePath: string } | null}
334
+ */
335
+ function savePendingTicket({ kind, endpoint, body }) {
336
+ let dir;
337
+ try {
338
+ dir = getPendingTicketsDir();
339
+ fs.mkdirSync(dir, { recursive: true });
340
+ } catch {
341
+ return null;
342
+ }
343
+
344
+ const id = generatePendingTicketId();
345
+ const filePath = getPendingTicketFilePath(id);
346
+ const record = {
347
+ id,
348
+ kind,
349
+ endpoint,
350
+ createdAt: new Date().toISOString(),
351
+ cwd: process.cwd(),
352
+ title: typeof body?.title === 'string' ? body.title : null,
353
+ ticketId: body?.ticketId ?? null,
354
+ projectId: body?.projectId ?? null,
355
+ objectives: describePendingObjectives(body),
356
+ body
357
+ };
358
+
359
+ try {
360
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
361
+ return { id, filePath };
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ /** Remove a pending draft once the server has confirmed the write. */
368
+ function resolvePendingTicket(id) {
369
+ if (!id) return;
370
+ try {
371
+ fs.unlinkSync(getPendingTicketFilePath(id));
372
+ } catch {
373
+ /* already gone — best effort */
374
+ }
375
+ }
376
+
377
+ /** Read every saved draft, skipping any that are missing or corrupt. */
378
+ function listPendingTickets() {
379
+ let entries;
380
+ try {
381
+ entries = fs.readdirSync(getPendingTicketsDir());
382
+ } catch {
383
+ return [];
384
+ }
385
+
386
+ const records = [];
387
+ for (const name of entries) {
388
+ if (!name.endsWith('.json')) continue;
389
+ try {
390
+ const raw = fs.readFileSync(path.join(getPendingTicketsDir(), name), 'utf8');
391
+ records.push(JSON.parse(raw));
392
+ } catch {
393
+ /* skip corrupt draft */
394
+ }
395
+ }
396
+ records.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
397
+ return records;
398
+ }
399
+
400
+ /**
401
+ * Post a new-ticket/objective request with local durability: save the body
402
+ * first, delete it only on a confirmed (2xx) response, and on any failure keep
403
+ * it and point the user at the recovery command. Mirrors apiPost's contract.
404
+ */
405
+ async function apiPostCreatingTicket(auth, endpoint, body, kind) {
406
+ const { platformUrl, bearerToken, localSecret, organizationId, timeoutMs } = auth;
407
+ const pending = savePendingTicket({ kind, endpoint, body });
408
+ if (pending) {
409
+ process.stderr.write(
410
+ `[protocol] saved local draft ${pending.id} before ${endpoint} (kept until the server confirms)\n`
411
+ );
412
+ }
413
+
414
+ let data;
415
+ try {
416
+ data = await apiPost(
417
+ platformUrl,
418
+ bearerToken,
419
+ localSecret,
420
+ organizationId,
421
+ endpoint,
422
+ body,
423
+ timeoutMs
424
+ );
425
+ } catch (error) {
426
+ if (pending && error instanceof Error) {
427
+ error.message +=
428
+ `\n\nYour ticket text was NOT lost — it was saved locally at:\n ${pending.filePath}\n` +
429
+ `List it with: ovld protocol pending-tickets\n` +
430
+ `Retry it with: ovld protocol pending-tickets --retry ${pending.id}`;
431
+ }
432
+ throw error;
433
+ }
434
+
435
+ if (pending) resolvePendingTicket(pending.id);
436
+ return data;
437
+ }
438
+
277
439
  /** Resolve request timeout from --timeout flag or OVERLORD_TIMEOUT env var. */
278
440
  function resolveTimeout(flags) {
279
441
  const raw = flags['timeout'] ?? process.env.OVERLORD_TIMEOUT;
@@ -420,7 +582,10 @@ async function readJsonFileOrStdin(filePath, label) {
420
582
  }
421
583
  }
422
584
 
423
- async function resolveObjectivesInput(flags, { payload = null, requireSingleObjective = true } = {}) {
585
+ async function resolveObjectivesInput(
586
+ flags,
587
+ { payload = null, requireSingleObjective = true } = {}
588
+ ) {
424
589
  if (flags['objectives-json'] && flags['objectives-file']) {
425
590
  throw new Error('Use either --objectives-json or --objectives-file, not both');
426
591
  }
@@ -436,7 +601,10 @@ async function resolveObjectivesInput(flags, { payload = null, requireSingleObje
436
601
  }
437
602
 
438
603
  if (flags['objectives-file']) {
439
- const objectives = await readJsonFileOrStdin(String(flags['objectives-file']), '--objectives-file');
604
+ const objectives = await readJsonFileOrStdin(
605
+ String(flags['objectives-file']),
606
+ '--objectives-file'
607
+ );
440
608
  if (!Array.isArray(objectives) || objectives.length === 0) {
441
609
  throw new Error('--objectives-file must contain a non-empty JSON array');
442
610
  }
@@ -934,13 +1102,122 @@ function detectClaudeSessionId() {
934
1102
  }
935
1103
  }
936
1104
 
1105
+ const CODEX_SESSION_UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
1106
+
1107
+ /**
1108
+ * Read the first line of a (potentially large) rollout file without slurping
1109
+ * the whole thing into memory. Codex writes the session meta as the first
1110
+ * JSONL record.
1111
+ */
1112
+ function readFirstLine(filePath, maxBytes = 16384) {
1113
+ let fd;
1114
+ try {
1115
+ fd = fs.openSync(filePath, 'r');
1116
+ const buffer = Buffer.alloc(maxBytes);
1117
+ const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
1118
+ const text = buffer.toString('utf8', 0, bytesRead);
1119
+ const newlineIdx = text.indexOf('\n');
1120
+ return newlineIdx === -1 ? text : text.slice(0, newlineIdx);
1121
+ } catch {
1122
+ return null;
1123
+ } finally {
1124
+ if (fd !== undefined) {
1125
+ try {
1126
+ fs.closeSync(fd);
1127
+ } catch {
1128
+ /* ignore */
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Extract the session id and recorded cwd from a Codex rollout meta line.
1136
+ * Newer Codex builds wrap the meta in a `{ type, payload }` envelope; older
1137
+ * builds emit the meta object directly. Returns null when no usable id exists.
1138
+ */
1139
+ export function extractCodexRolloutMeta(line) {
1140
+ if (!line) return null;
1141
+ let parsed;
1142
+ try {
1143
+ parsed = JSON.parse(line);
1144
+ } catch {
1145
+ return null;
1146
+ }
1147
+ if (!parsed || typeof parsed !== 'object') return null;
1148
+ const meta = parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : parsed;
1149
+ const idMatch = typeof meta.id === 'string' ? meta.id.match(CODEX_SESSION_UUID_RE) : null;
1150
+ const cwd = typeof meta.cwd === 'string' ? meta.cwd : null;
1151
+ return idMatch ? { id: idMatch[0], cwd } : null;
1152
+ }
1153
+
1154
+ /**
1155
+ * Detect the active Codex session id from disk when the runtime env vars are
1156
+ * absent. Codex persists rollout files under
1157
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl. We pick the
1158
+ * most recently modified rollout whose recorded cwd matches the current
1159
+ * working directory, falling back to the most recent rollout overall.
1160
+ */
1161
+ export function detectCodexSessionIdFromDisk() {
1162
+ try {
1163
+ const sessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1164
+ if (!fs.existsSync(sessionsDir)) return null;
1165
+
1166
+ const cwd = process.cwd();
1167
+ const candidates = [];
1168
+ const walk = (dir, depth) => {
1169
+ if (depth > 5 || candidates.length > 1000) return;
1170
+ let entries;
1171
+ try {
1172
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1173
+ } catch {
1174
+ return;
1175
+ }
1176
+ for (const entry of entries) {
1177
+ const full = path.join(dir, entry.name);
1178
+ if (entry.isDirectory()) {
1179
+ walk(full, depth + 1);
1180
+ } else if (
1181
+ entry.isFile() &&
1182
+ entry.name.startsWith('rollout-') &&
1183
+ entry.name.endsWith('.jsonl')
1184
+ ) {
1185
+ try {
1186
+ candidates.push({ full, name: entry.name, mtime: fs.statSync(full).mtimeMs });
1187
+ } catch {
1188
+ /* ignore unreadable file */
1189
+ }
1190
+ }
1191
+ }
1192
+ };
1193
+ walk(sessionsDir, 0);
1194
+
1195
+ if (candidates.length === 0) return null;
1196
+ candidates.sort((a, b) => b.mtime - a.mtime);
1197
+
1198
+ let fallbackId = null;
1199
+ for (const candidate of candidates.slice(0, 25)) {
1200
+ const meta = extractCodexRolloutMeta(readFirstLine(candidate.full));
1201
+ const id = meta?.id ?? candidate.name.match(CODEX_SESSION_UUID_RE)?.[0] ?? null;
1202
+ if (!id) continue;
1203
+ if (fallbackId === null) fallbackId = id;
1204
+ if (meta?.cwd && meta.cwd === cwd) return id;
1205
+ }
1206
+ return fallbackId;
1207
+ } catch {
1208
+ return null;
1209
+ }
1210
+ }
1211
+
937
1212
  /**
938
- * Codex exposes the active resumable thread id directly in the runtime
939
- * environment, so prefer that over filesystem heuristics.
1213
+ * Codex exposes the active resumable thread id in the runtime environment, but
1214
+ * does not set those vars reliably across launch paths. Prefer the env var when
1215
+ * present, otherwise fall back to reading the active rollout file from disk.
940
1216
  */
941
- function detectCodexSessionId() {
942
- const sessionId = process.env.CODEX_THREAD_ID?.trim() || process.env.CODEX_SESSION_ID?.trim();
943
- return sessionId || null;
1217
+ export function detectCodexSessionId() {
1218
+ const fromEnv = process.env.CODEX_THREAD_ID?.trim() || process.env.CODEX_SESSION_ID?.trim();
1219
+ if (fromEnv) return fromEnv;
1220
+ return detectCodexSessionIdFromDisk();
944
1221
  }
945
1222
 
946
1223
  /**
@@ -1758,7 +2035,9 @@ async function protocolGetDevice(args) {
1758
2035
  await resolveProtocolAuthForFlags(flags);
1759
2036
  const timeoutMs = resolveTimeout(flags);
1760
2037
 
1761
- const deviceFingerprint = String(flags['device-fingerprint'] ?? process.env.OVERLORD_DEVICE_FINGERPRINT ?? '');
2038
+ const deviceFingerprint = String(
2039
+ flags['device-fingerprint'] ?? process.env.OVERLORD_DEVICE_FINGERPRINT ?? ''
2040
+ );
1762
2041
  if (!deviceFingerprint) {
1763
2042
  console.error('Error: --device-fingerprint is required (or set OVERLORD_DEVICE_FINGERPRINT)');
1764
2043
  process.exit(1);
@@ -1800,7 +2079,9 @@ async function protocolUpdateDevice(args) {
1800
2079
  await resolveProtocolAuthForFlags(flags);
1801
2080
  const timeoutMs = resolveTimeout(flags);
1802
2081
 
1803
- const deviceFingerprint = String(flags['device-fingerprint'] ?? process.env.OVERLORD_DEVICE_FINGERPRINT ?? '');
2082
+ const deviceFingerprint = String(
2083
+ flags['device-fingerprint'] ?? process.env.OVERLORD_DEVICE_FINGERPRINT ?? ''
2084
+ );
1804
2085
  if (!deviceFingerprint) {
1805
2086
  console.error('Error: --device-fingerprint is required (or set OVERLORD_DEVICE_FINGERPRINT)');
1806
2087
  process.exit(1);
@@ -1839,9 +2120,10 @@ async function protocolListProjectResources(args) {
1839
2120
  process.exit(1);
1840
2121
  }
1841
2122
 
1842
- const deviceFingerprint = typeof flags['device-fingerprint'] === 'string'
1843
- ? flags['device-fingerprint'].trim()
1844
- : (process.env.OVERLORD_DEVICE_FINGERPRINT?.trim() ?? '');
2123
+ const deviceFingerprint =
2124
+ typeof flags['device-fingerprint'] === 'string'
2125
+ ? flags['device-fingerprint'].trim()
2126
+ : (process.env.OVERLORD_DEVICE_FINGERPRINT?.trim() ?? '');
1845
2127
 
1846
2128
  const body = {
1847
2129
  projectId,
@@ -1871,9 +2153,8 @@ async function protocolAddProjectResource(args) {
1871
2153
  const timeoutMs = resolveTimeout(flags);
1872
2154
 
1873
2155
  const projectId = typeof flags['project-id'] === 'string' ? flags['project-id'].trim() : '';
1874
- const directoryPath = typeof flags['directory'] === 'string'
1875
- ? flags['directory'].trim()
1876
- : process.cwd();
2156
+ const directoryPath =
2157
+ typeof flags['directory'] === 'string' ? flags['directory'].trim() : process.cwd();
1877
2158
  const deviceFingerprint = String(
1878
2159
  flags['device-fingerprint'] ?? process.env.OVERLORD_DEVICE_FINGERPRINT ?? ''
1879
2160
  );
@@ -2496,14 +2777,11 @@ async function protocolPrompt(args) {
2496
2777
  ...(parentTicketId ? { parentTicketId } : {})
2497
2778
  };
2498
2779
 
2499
- const data = await apiPost(
2500
- platformUrl,
2501
- bearerToken,
2502
- localSecret,
2503
- organizationId,
2780
+ const data = await apiPostCreatingTicket(
2781
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2504
2782
  '/api/protocol/prompt',
2505
2783
  body,
2506
- timeoutMs
2784
+ 'prompt'
2507
2785
  );
2508
2786
 
2509
2787
  const sessionKey = data.session?.sessionKey;
@@ -2582,14 +2860,11 @@ async function protocolCreateTicket(args) {
2582
2860
  ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {})
2583
2861
  };
2584
2862
 
2585
- const data = await apiPost(
2586
- platformUrl,
2587
- bearerToken,
2588
- localSecret,
2589
- organizationId,
2863
+ const data = await apiPostCreatingTicket(
2864
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2590
2865
  '/api/protocol/create-ticket',
2591
2866
  body,
2592
- timeoutMs
2867
+ 'create'
2593
2868
  );
2594
2869
  console.log(JSON.stringify(data, null, 2));
2595
2870
  return;
@@ -2623,14 +2898,11 @@ async function protocolCreateTicket(args) {
2623
2898
  ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {})
2624
2899
  };
2625
2900
 
2626
- const data = await apiPost(
2627
- platformUrl,
2628
- bearerToken,
2629
- localSecret,
2630
- organizationId,
2901
+ const data = await apiPostCreatingTicket(
2902
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2631
2903
  '/api/protocol/tickets',
2632
2904
  standaloneBody,
2633
- timeoutMs
2905
+ 'create'
2634
2906
  );
2635
2907
  console.log(JSON.stringify(data, null, 2));
2636
2908
  }
@@ -2723,14 +2995,11 @@ async function protocolRecordWork(args) {
2723
2995
  await resolveProtocolAuthForFlags(flags);
2724
2996
  const timeoutMs = resolveTimeout(flags);
2725
2997
 
2726
- const data = await apiPost(
2727
- platformUrl,
2728
- bearerToken,
2729
- localSecret,
2730
- organizationId,
2998
+ const data = await apiPostCreatingTicket(
2999
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2731
3000
  '/api/protocol/record-work',
2732
3001
  body,
2733
- timeoutMs
3002
+ 'record-work'
2734
3003
  );
2735
3004
  console.log(JSON.stringify(data, null, 2));
2736
3005
 
@@ -2755,21 +3024,103 @@ async function protocolAddObjectives(args) {
2755
3024
  await resolveProtocolAuthForFlags(flags, ticketId);
2756
3025
  const timeoutMs = resolveTimeout(flags);
2757
3026
 
2758
- const data = await apiPost(
2759
- platformUrl,
2760
- bearerToken,
2761
- localSecret,
2762
- organizationId,
3027
+ const data = await apiPostCreatingTicket(
3028
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2763
3029
  '/api/protocol/add-objectives',
2764
3030
  {
2765
3031
  ticketId,
2766
3032
  objectives: objectiveInput.objectives
2767
3033
  },
2768
- timeoutMs
3034
+ 'add-objectives'
2769
3035
  );
2770
3036
  console.log(JSON.stringify(data, null, 2));
2771
3037
  }
2772
3038
 
3039
+ // ---------------------------------------------------------------------------
3040
+ // pending-tickets (inspect / retry / clear locally-saved unconfirmed drafts)
3041
+ // ---------------------------------------------------------------------------
3042
+
3043
+ async function protocolPendingTickets(args) {
3044
+ const flags = parseFlags(args);
3045
+
3046
+ if (flags['clear-all']) {
3047
+ const records = listPendingTickets();
3048
+ for (const record of records) resolvePendingTicket(record.id);
3049
+ console.log(JSON.stringify({ cleared: records.length }, null, 2));
3050
+ return;
3051
+ }
3052
+
3053
+ if (flags.clear) {
3054
+ const id = String(flags.clear);
3055
+ resolvePendingTicket(id);
3056
+ console.log(JSON.stringify({ cleared: id }, null, 2));
3057
+ return;
3058
+ }
3059
+
3060
+ if (flags.retry) {
3061
+ const id = String(flags.retry);
3062
+ const record = listPendingTickets().find(entry => entry.id === id);
3063
+ if (!record) {
3064
+ throw new Error(
3065
+ `No pending ticket draft with id "${id}". Run \`ovld protocol pending-tickets\` to list drafts.`
3066
+ );
3067
+ }
3068
+ if (!record.endpoint || !record.body) {
3069
+ throw new Error(
3070
+ `Pending draft "${id}" is missing the original request and cannot be retried.`
3071
+ );
3072
+ }
3073
+
3074
+ const ticketIdHint = record.ticketId ?? record.body?.ticketId ?? '';
3075
+ const { platformUrl, bearerToken, localSecret, organizationId } =
3076
+ await resolveProtocolAuthForFlags(flags, ticketIdHint);
3077
+ const timeoutMs = resolveTimeout(flags);
3078
+
3079
+ const data = await apiPost(
3080
+ platformUrl,
3081
+ bearerToken,
3082
+ localSecret,
3083
+ organizationId,
3084
+ record.endpoint,
3085
+ record.body,
3086
+ timeoutMs
3087
+ );
3088
+ resolvePendingTicket(id);
3089
+ console.log(JSON.stringify(data, null, 2));
3090
+ return;
3091
+ }
3092
+
3093
+ const records = listPendingTickets();
3094
+
3095
+ if (flags.json) {
3096
+ console.log(JSON.stringify(records, null, 2));
3097
+ return;
3098
+ }
3099
+
3100
+ if (records.length === 0) {
3101
+ console.log('No pending ticket drafts. Everything you created was confirmed by the server.');
3102
+ return;
3103
+ }
3104
+
3105
+ const lines = [`${records.length} pending ticket draft(s) not yet confirmed by the server:`, ''];
3106
+ for (const record of records) {
3107
+ const objectiveText = record.objectives?.length
3108
+ ? record.objectives.join(' | ')
3109
+ : (record.title ?? '(no objective text captured)');
3110
+ lines.push(`• ${record.id} [${record.kind}] ${record.createdAt}`);
3111
+ if (record.title) lines.push(` title: ${record.title}`);
3112
+ lines.push(` text: ${truncateForDisplay(objectiveText)}`);
3113
+ if (record.ticketId) lines.push(` ticket: ${record.ticketId}`);
3114
+ lines.push(` file: ${getPendingTicketFilePath(record.id)}`);
3115
+ lines.push('');
3116
+ }
3117
+ lines.push('These drafts were saved before the server confirmed them.');
3118
+ lines.push(' Retry a draft: ovld protocol pending-tickets --retry <id>');
3119
+ lines.push(' Clear a draft: ovld protocol pending-tickets --clear <id>');
3120
+ lines.push(' Clear all: ovld protocol pending-tickets --clear-all');
3121
+ console.log(lines.join('\n'));
3122
+ }
3123
+
2773
3124
  // ---------------------------------------------------------------------------
2774
3125
  // search-tickets (find tickets by query/status/project/created_by/dates)
2775
3126
  // ---------------------------------------------------------------------------
@@ -2877,6 +3228,7 @@ Subcommands:
2877
3228
  search-tickets Find tickets by keyword, status, project, creator, or update date
2878
3229
  discuss-objective Mark a draft objective as submitted (ready for review/execution)
2879
3230
  add-objectives Append ordered objectives to an existing ticket
3231
+ pending-tickets List, retry, or clear locally-saved drafts the server never confirmed
2880
3232
  create Create a draft ticket without attaching (follow-up or standalone)
2881
3233
  prompt Create a ticket and attach to it immediately
2882
3234
  record-work Record completed-from-chat work as a ticket in review + feed post (no attach)
@@ -3232,6 +3584,22 @@ add-objectives:
3232
3584
  goal. Create multiple tickets instead when prompts describe different
3233
3585
  features or goals.
3234
3586
 
3587
+ pending-tickets:
3588
+ Purpose:
3589
+ Inspect, retry, or clear ticket/objective drafts that were saved locally
3590
+ before a create/prompt/add-objectives/record-work call but never confirmed
3591
+ by the server (e.g. the network dropped). Drafts are written before the
3592
+ request and deleted automatically once the server confirms the write, so a
3593
+ flaky connection never loses your text.
3594
+ Usage:
3595
+ --json Print the raw saved drafts as JSON instead of a summary
3596
+ --retry <id> Re-send a saved draft to the server; clears it on success
3597
+ --clear <id> Delete a single saved draft after confirming it landed
3598
+ --clear-all Delete every saved draft
3599
+ Notes:
3600
+ Drafts live under ~/.overlord/pending-tickets/. With no flags the command
3601
+ lists outstanding drafts with their captured objective text.
3602
+
3235
3603
  record-work:
3236
3604
  Purpose:
3237
3605
  Record work the agent already completed in a chat as a ticket in \`review\` status
@@ -3474,6 +3842,9 @@ Examples:
3474
3842
  ovld protocol create --agent codex --objectives-json '[{"objective":"Step one"},{"objective":"Step two"}]'
3475
3843
  ovld protocol create --agent codex --session-key <key> --ticket-id <ticket_id> --objective "Capture follow-up work"
3476
3844
  ovld protocol prompt --agent codex --objective "Implement user auth" --priority high
3845
+ ovld protocol pending-tickets
3846
+ ovld protocol pending-tickets --retry <draft-id>
3847
+ ovld protocol pending-tickets --clear-all
3477
3848
  ovld protocol update --session-key <key> --ticket-id <ticket_id> --summary "Did X" --phase execute
3478
3849
  ovld protocol update --session-key <key> --ticket-id <ticket_id> --summary-file ./update.txt --event-type user_follow_up
3479
3850
  ovld protocol record-change-rationales --session-key <key> --ticket-id <ticket_id> --change-rationales-json '[{"label":"...","file_path":"...","summary":"...","why":"...","impact":"...","hunks":[{"header":"@@ ... @@"}]}]'
@@ -3553,6 +3924,10 @@ EOF
3553
3924
  await protocolAddObjectives(args);
3554
3925
  return;
3555
3926
  }
3927
+ if (subcommand === 'pending-tickets') {
3928
+ await protocolPendingTickets(args);
3929
+ return;
3930
+ }
3556
3931
  if (subcommand === 'create' || subcommand === 'create-ticket') {
3557
3932
  await protocolCreateTicket(args);
3558
3933
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord-cli",
3
- "version": "0.2606070914.0",
3
+ "version": "0.2606081020.0",
4
4
  "description": "Overlord CLI — launch AI agents on tickets from anywhere",
5
5
  "homepage": "https://www.ovld.ai/docs/",
6
6
  "type": "module",
@@ -132,6 +132,17 @@ ovld protocol prompt --agent claude-code --objectives-json '[{"objective":"Imple
132
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
133
133
  ```
134
134
 
135
+ ### Local Durability For New Tickets
136
+
137
+ `create`, `prompt`, `add-objectives`, and `record-work` save the objective/ticket text to a local draft (`~/.overlord/pending-tickets/`) **before** sending it, and delete that draft only once the server confirms the write. If the network drops mid-call, your text is never lost — the failure message points at the saved file. Manage outstanding drafts with `pending-tickets`:
138
+
139
+ ```bash
140
+ ovld protocol pending-tickets # list drafts the server never confirmed
141
+ ovld protocol pending-tickets --retry <id> # re-send a saved draft; clears it on success
142
+ ovld protocol pending-tickets --clear <id> # delete one draft after confirming it landed
143
+ ovld protocol pending-tickets --clear-all # delete every draft
144
+ ```
145
+
135
146
  To inspect project resolution explicitly:
136
147
 
137
148
  ```bash
@@ -132,6 +132,17 @@ ovld protocol prompt --agent cursor --objectives-json '[{"objective":"Implement
132
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
133
133
  ```
134
134
 
135
+ ### Local Durability For New Tickets
136
+
137
+ `create`, `prompt`, `add-objectives`, and `record-work` save the objective/ticket text to a local draft (`~/.overlord/pending-tickets/`) **before** sending it, and delete that draft only once the server confirms the write. If the network drops mid-call, your text is never lost — the failure message points at the saved file. Manage outstanding drafts with `pending-tickets`:
138
+
139
+ ```bash
140
+ ovld protocol pending-tickets # list drafts the server never confirmed
141
+ ovld protocol pending-tickets --retry <id> # re-send a saved draft; clears it on success
142
+ ovld protocol pending-tickets --clear <id> # delete one draft after confirming it landed
143
+ ovld protocol pending-tickets --clear-all # delete every draft
144
+ ```
145
+
135
146
  To inspect project resolution explicitly:
136
147
 
137
148
  ```bash
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Chat-driven access to Overlord ticket protocol workflows with hook-driven activity capture.",
5
5
  "author": {
6
6
  "name": "Cooperativ",
@@ -32,12 +32,73 @@ if [ -n "$OVERLORD_BASE_URL" ] && [ -n "$OVERLORD_TOKEN" ] && [ -n "$TICKET_ID"
32
32
  import base64
33
33
  import json
34
34
  import os
35
+ import re
35
36
  import sys
36
37
  import tempfile
37
38
  from pathlib import Path
38
39
 
40
+ UUID_RE = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', re.I)
41
+
42
+
43
+ def detect_codex_session_id_from_disk():
44
+ # Codex does not reliably export CODEX_THREAD_ID/CODEX_SESSION_ID, so fall
45
+ # back to the active rollout file under ~/.codex/sessions. Prefer the most
46
+ # recent rollout whose recorded cwd matches ours.
47
+ try:
48
+ sessions_dir = Path.home() / '.codex' / 'sessions'
49
+ if not sessions_dir.is_dir():
50
+ return None
51
+ cwd = os.getcwd()
52
+ candidates = []
53
+ for entry in sessions_dir.rglob('rollout-*.jsonl'):
54
+ try:
55
+ candidates.append((entry.stat().st_mtime, entry))
56
+ except OSError:
57
+ continue
58
+ if len(candidates) > 1000:
59
+ break
60
+ if not candidates:
61
+ return None
62
+ candidates.sort(key=lambda item: item[0], reverse=True)
63
+ fallback_id = None
64
+ for _, entry in candidates[:25]:
65
+ meta_id = None
66
+ meta_cwd = None
67
+ try:
68
+ with entry.open('r', encoding='utf-8', errors='replace') as handle:
69
+ first_line = handle.readline()
70
+ obj = json.loads(first_line)
71
+ meta = obj.get('payload') if isinstance(obj.get('payload'), dict) else obj
72
+ if isinstance(meta, dict):
73
+ raw_id = meta.get('id')
74
+ if isinstance(raw_id, str):
75
+ match = UUID_RE.search(raw_id)
76
+ meta_id = match.group(0) if match else None
77
+ if isinstance(meta.get('cwd'), str):
78
+ meta_cwd = meta.get('cwd')
79
+ except Exception:
80
+ meta_id = None
81
+ if not meta_id:
82
+ match = UUID_RE.search(entry.name)
83
+ meta_id = match.group(0) if match else None
84
+ if not meta_id:
85
+ continue
86
+ if fallback_id is None:
87
+ fallback_id = meta_id
88
+ if meta_cwd and meta_cwd == cwd:
89
+ return meta_id
90
+ return fallback_id
91
+ except Exception:
92
+ return None
93
+
94
+
39
95
  body = json.load(sys.stdin)
40
- external_session_id = os.environ.get('CODEX_THREAD_ID') or os.environ.get('CODEX_SESSION_ID') or None
96
+ external_session_id = (
97
+ os.environ.get('CODEX_THREAD_ID')
98
+ or os.environ.get('CODEX_SESSION_ID')
99
+ or detect_codex_session_id_from_disk()
100
+ or None
101
+ )
41
102
  session_key = os.environ.get('SESSION_KEY') or ''
42
103
 
43
104
  if not session_key:
@@ -132,6 +132,17 @@ ovld protocol prompt --agent codex --objectives-json '[{"objective":"Implement f
132
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
133
133
  ```
134
134
 
135
+ ### Local Durability For New Tickets
136
+
137
+ `create`, `prompt`, `add-objectives`, and `record-work` save the objective/ticket text to a local draft (`~/.overlord/pending-tickets/`) **before** sending it, and delete that draft only once the server confirms the write. If the network drops mid-call, your text is never lost — the failure message points at the saved file. Manage outstanding drafts with `pending-tickets`:
138
+
139
+ ```bash
140
+ ovld protocol pending-tickets # list drafts the server never confirmed
141
+ ovld protocol pending-tickets --retry <id> # re-send a saved draft; clears it on success
142
+ ovld protocol pending-tickets --clear <id> # delete one draft after confirming it landed
143
+ ovld protocol pending-tickets --clear-all # delete every draft
144
+ ```
145
+
135
146
  To inspect project resolution explicitly:
136
147
 
137
148
  ```bash