overlord-cli 0.2606060856.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
  );
@@ -2489,20 +2770,18 @@ async function protocolPrompt(args) {
2489
2770
  ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
2490
2771
  ...(forHuman !== undefined ? { forHuman } : {}),
2491
2772
  delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier),
2773
+ ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {}),
2492
2774
  ...(flags['parent-session-key']
2493
2775
  ? { parentSessionKey: String(flags['parent-session-key']) }
2494
2776
  : {}),
2495
2777
  ...(parentTicketId ? { parentTicketId } : {})
2496
2778
  };
2497
2779
 
2498
- const data = await apiPost(
2499
- platformUrl,
2500
- bearerToken,
2501
- localSecret,
2502
- organizationId,
2780
+ const data = await apiPostCreatingTicket(
2781
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2503
2782
  '/api/protocol/prompt',
2504
2783
  body,
2505
- timeoutMs
2784
+ 'prompt'
2506
2785
  );
2507
2786
 
2508
2787
  const sessionKey = data.session?.sessionKey;
@@ -2577,17 +2856,15 @@ async function protocolCreateTicket(args) {
2577
2856
  : {}),
2578
2857
  ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
2579
2858
  ...(forHuman !== undefined ? { forHuman } : {}),
2580
- delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier)
2859
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier),
2860
+ ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {})
2581
2861
  };
2582
2862
 
2583
- const data = await apiPost(
2584
- platformUrl,
2585
- bearerToken,
2586
- localSecret,
2587
- organizationId,
2863
+ const data = await apiPostCreatingTicket(
2864
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2588
2865
  '/api/protocol/create-ticket',
2589
2866
  body,
2590
- timeoutMs
2867
+ 'create'
2591
2868
  );
2592
2869
  console.log(JSON.stringify(data, null, 2));
2593
2870
  return;
@@ -2617,17 +2894,15 @@ async function protocolCreateTicket(args) {
2617
2894
  : {}),
2618
2895
  ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
2619
2896
  ...(forHuman !== undefined ? { forHuman } : {}),
2620
- delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier)
2897
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier),
2898
+ ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {})
2621
2899
  };
2622
2900
 
2623
- const data = await apiPost(
2624
- platformUrl,
2625
- bearerToken,
2626
- localSecret,
2627
- organizationId,
2901
+ const data = await apiPostCreatingTicket(
2902
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2628
2903
  '/api/protocol/tickets',
2629
2904
  standaloneBody,
2630
- timeoutMs
2905
+ 'create'
2631
2906
  );
2632
2907
  console.log(JSON.stringify(data, null, 2));
2633
2908
  }
@@ -2712,21 +2987,19 @@ async function protocolRecordWork(args) {
2712
2987
  ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
2713
2988
  : {}),
2714
2989
  ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
2715
- delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier)
2990
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier),
2991
+ ...(flags['assigned-to'] ? { assignedTo: String(flags['assigned-to']) } : {})
2716
2992
  };
2717
2993
 
2718
2994
  const { platformUrl, bearerToken, localSecret, organizationId } =
2719
2995
  await resolveProtocolAuthForFlags(flags);
2720
2996
  const timeoutMs = resolveTimeout(flags);
2721
2997
 
2722
- const data = await apiPost(
2723
- platformUrl,
2724
- bearerToken,
2725
- localSecret,
2726
- organizationId,
2998
+ const data = await apiPostCreatingTicket(
2999
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2727
3000
  '/api/protocol/record-work',
2728
3001
  body,
2729
- timeoutMs
3002
+ 'record-work'
2730
3003
  );
2731
3004
  console.log(JSON.stringify(data, null, 2));
2732
3005
 
@@ -2751,21 +3024,103 @@ async function protocolAddObjectives(args) {
2751
3024
  await resolveProtocolAuthForFlags(flags, ticketId);
2752
3025
  const timeoutMs = resolveTimeout(flags);
2753
3026
 
2754
- const data = await apiPost(
2755
- platformUrl,
2756
- bearerToken,
2757
- localSecret,
2758
- organizationId,
3027
+ const data = await apiPostCreatingTicket(
3028
+ { platformUrl, bearerToken, localSecret, organizationId, timeoutMs },
2759
3029
  '/api/protocol/add-objectives',
2760
3030
  {
2761
3031
  ticketId,
2762
3032
  objectives: objectiveInput.objectives
2763
3033
  },
2764
- timeoutMs
3034
+ 'add-objectives'
2765
3035
  );
2766
3036
  console.log(JSON.stringify(data, null, 2));
2767
3037
  }
2768
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
+
2769
3124
  // ---------------------------------------------------------------------------
2770
3125
  // search-tickets (find tickets by query/status/project/created_by/dates)
2771
3126
  // ---------------------------------------------------------------------------
@@ -2873,6 +3228,7 @@ Subcommands:
2873
3228
  search-tickets Find tickets by keyword, status, project, creator, or update date
2874
3229
  discuss-objective Mark a draft objective as submitted (ready for review/execution)
2875
3230
  add-objectives Append ordered objectives to an existing ticket
3231
+ pending-tickets List, retry, or clear locally-saved drafts the server never confirmed
2876
3232
  create Create a draft ticket without attaching (follow-up or standalone)
2877
3233
  prompt Create a ticket and attach to it immediately
2878
3234
  record-work Record completed-from-chat work as a ticket in review + feed post (no attach)
@@ -3164,6 +3520,7 @@ prompt:
3164
3520
  --for-human Mark the new ticket as requiring a human
3165
3521
  --execution-target <t> Deprecated alias: agent | human
3166
3522
  --delegate <model> Model or delegate identifier that created the ticket
3523
+ --assigned-to <member> Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator)
3167
3524
  --parent-session-key <key>
3168
3525
  --parent-ticket-id <ticket_id>
3169
3526
  --agent <identifier>
@@ -3204,6 +3561,7 @@ create:
3204
3561
  --for-human Mark the new ticket as requiring a human
3205
3562
  --execution-target <t> Deprecated alias: agent | human
3206
3563
  --delegate <model> Model or delegate identifier that created the ticket
3564
+ --assigned-to <member> Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator)
3207
3565
  --agent <identifier>
3208
3566
  --model <identifier>
3209
3567
  Returns:
@@ -3226,6 +3584,22 @@ add-objectives:
3226
3584
  goal. Create multiple tickets instead when prompts describe different
3227
3585
  features or goals.
3228
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
+
3229
3603
  record-work:
3230
3604
  Purpose:
3231
3605
  Record work the agent already completed in a chat as a ticket in \`review\` status
@@ -3252,6 +3626,7 @@ record-work:
3252
3626
  --acceptance-criteria <text>
3253
3627
  --available-tools <text>
3254
3628
  --delegate <model>
3629
+ --assigned-to <member> Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator)
3255
3630
  --agent <identifier>
3256
3631
  --model <identifier>
3257
3632
  Notes:
@@ -3467,6 +3842,9 @@ Examples:
3467
3842
  ovld protocol create --agent codex --objectives-json '[{"objective":"Step one"},{"objective":"Step two"}]'
3468
3843
  ovld protocol create --agent codex --session-key <key> --ticket-id <ticket_id> --objective "Capture follow-up work"
3469
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
3470
3848
  ovld protocol update --session-key <key> --ticket-id <ticket_id> --summary "Did X" --phase execute
3471
3849
  ovld protocol update --session-key <key> --ticket-id <ticket_id> --summary-file ./update.txt --event-type user_follow_up
3472
3850
  ovld protocol record-change-rationales --session-key <key> --ticket-id <ticket_id> --change-rationales-json '[{"label":"...","file_path":"...","summary":"...","why":"...","impact":"...","hunks":[{"header":"@@ ... @@"}]}]'
@@ -3546,6 +3924,10 @@ EOF
3546
3924
  await protocolAddObjectives(args);
3547
3925
  return;
3548
3926
  }
3927
+ if (subcommand === 'pending-tickets') {
3928
+ await protocolPendingTickets(args);
3929
+ return;
3930
+ }
3549
3931
  if (subcommand === 'create' || subcommand === 'create-ticket') {
3550
3932
  await protocolCreateTicket(args);
3551
3933
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord-cli",
3
- "version": "0.2606060856.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",
@@ -8,7 +8,7 @@ Create a draft Overlord ticket from the user's request.
8
8
 
9
9
 
10
10
  Use `$ARGUMENTS` as the input.
11
- If it already contains flags such as `--title`, `--priority`, `--project-id`, or `--for-human`, pass those flags through after `ovld protocol create --agent claude-code`.
11
+ If it already contains flags such as `--title`, `--priority`, `--project-id`, `--assigned-to`, or `--for-human`, pass those flags through after `ovld protocol create --agent claude-code`.
12
12
  Otherwise, treat `$ARGUMENTS` as the objective text and run:
13
13
  `ovld protocol create --agent claude-code --objective "<objective>"`
14
14
 
@@ -8,7 +8,7 @@ Create a new Overlord ticket from the user's request.
8
8
 
9
9
 
10
10
  Use `$ARGUMENTS` as the input.
11
- If it already contains flags such as `--title`, `--priority`, `--project-id`, or `--for-human`, pass those flags through after `ovld protocol prompt --agent claude-code`.
11
+ If it already contains flags such as `--title`, `--priority`, `--project-id`, `--assigned-to`, or `--for-human`, pass those flags through after `ovld protocol prompt --agent claude-code`.
12
12
  Otherwise, treat `$ARGUMENTS` as the objective text and run:
13
13
  `ovld protocol prompt --agent claude-code --objective "<objective>"`
14
14
 
@@ -9,7 +9,7 @@ This command is deprecated. Use `/overlord:prompt` instead.
9
9
  Create a new Overlord ticket from the user's request.
10
10
 
11
11
  Use `$ARGUMENTS` as the input.
12
- If it already contains flags such as `--title`, `--priority`, `--project-id`, or `--for-human`, pass those flags through after `ovld protocol prompt --agent claude-code`.
12
+ If it already contains flags such as `--title`, `--priority`, `--project-id`, `--assigned-to`, or `--for-human`, pass those flags through after `ovld protocol prompt --agent claude-code`.
13
13
  Otherwise, treat `$ARGUMENTS` as the objective text and run:
14
14
  `ovld protocol prompt --agent claude-code --objective "<objective>"`
15
15
 
@@ -118,6 +118,7 @@ When creating tickets from within a repository:
118
118
  - Create multiple tickets when each prompt represents a different feature or goal.
119
119
  - Add objectives to the same ticket when each prompt is a sequential step toward the same feature or goal; use `ovld protocol add-objectives --ticket-id <ticket_id> --objectives-json '[{"objective":"..."}]'`.
120
120
  - `create`, `prompt`, and `record-work` require `--objectives-json` or `--objectives-file` with an ordered array of `{ "objective": "...", "title": "...", "autoAdvance": true }` objects. A single objective is just an array with one item.
121
+ - `create`, `prompt`, `create-ticket`, and `record-work` accept `--assigned-to <member>` to set the ticket's human owner. Accepts a username, an email, a user-id UUID, or the `orgid:username` member ID. When omitted, the assignee defaults to the ticket creator.
121
122
 
122
123
  ```bash
123
124
  ovld protocol create --agent claude-code --objectives-json '[{"objective":"Capture follow-up work from this repository"}]'
@@ -131,6 +132,17 @@ ovld protocol prompt --agent claude-code --objectives-json '[{"objective":"Imple
131
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
132
133
  ```
133
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
+
134
146
  To inspect project resolution explicitly:
135
147
 
136
148
  ```bash
@@ -118,6 +118,7 @@ When creating tickets from within a repository:
118
118
  - Create multiple tickets when each prompt represents a different feature or goal.
119
119
  - Add objectives to the same ticket when each prompt is a sequential step toward the same feature or goal; use `ovld protocol add-objectives --ticket-id <ticket_id> --objectives-json '[{"objective":"..."}]'`.
120
120
  - `create`, `prompt`, and `record-work` require `--objectives-json` or `--objectives-file` with an ordered array of `{ "objective": "...", "title": "...", "autoAdvance": true }` objects. A single objective is just an array with one item.
121
+ - `create`, `prompt`, `create-ticket`, and `record-work` accept `--assigned-to <member>` to set the ticket's human owner. Accepts a username, an email, a user-id UUID, or the `orgid:username` member ID. When omitted, the assignee defaults to the ticket creator.
121
122
 
122
123
  ```bash
123
124
  ovld protocol create --agent cursor --objectives-json '[{"objective":"Capture follow-up work from this repository"}]'
@@ -131,6 +132,17 @@ ovld protocol prompt --agent cursor --objectives-json '[{"objective":"Implement
131
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
132
133
  ```
133
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
+
134
146
  To inspect project resolution explicitly:
135
147
 
136
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",
@@ -244,6 +244,11 @@ const tools = [
244
244
  available_tools: { type: 'string' },
245
245
  for_human: { type: 'boolean' },
246
246
  delegate: { type: 'string' },
247
+ assigned_to: {
248
+ type: 'string',
249
+ description:
250
+ 'Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator).'
251
+ },
247
252
  parent_session_key: { type: 'string' },
248
253
  parent_ticket_id: { type: 'string' },
249
254
  agent: { type: 'string' },
@@ -262,6 +267,7 @@ const tools = [
262
267
  'available-tools': args.available_tools,
263
268
  'for-human': args.for_human === true ? true : undefined,
264
269
  delegate: args.delegate,
270
+ 'assigned-to': args.assigned_to,
265
271
  'parent-session-key': args.parent_session_key,
266
272
  'parent-ticket-id': args.parent_ticket_id,
267
273
  agent: args.agent,
@@ -286,6 +292,11 @@ const tools = [
286
292
  available_tools: { type: 'string' },
287
293
  for_human: { type: 'boolean' },
288
294
  delegate: { type: 'string' },
295
+ assigned_to: {
296
+ type: 'string',
297
+ description:
298
+ 'Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator).'
299
+ },
289
300
  session_key: { type: 'string' },
290
301
  ticket_id: { type: 'string' },
291
302
  agent: { type: 'string' }
@@ -303,6 +314,7 @@ const tools = [
303
314
  'available-tools': args.available_tools,
304
315
  'for-human': args.for_human === true ? true : undefined,
305
316
  delegate: args.delegate,
317
+ 'assigned-to': args.assigned_to,
306
318
  'session-key': args.session_key,
307
319
  'ticket-id': args.ticket_id,
308
320
  agent: args.agent
@@ -628,6 +640,11 @@ const tools = [
628
640
  acceptance_criteria: { type: 'string' },
629
641
  available_tools: { type: 'string' },
630
642
  delegate: { type: 'string' },
643
+ assigned_to: {
644
+ type: 'string',
645
+ description:
646
+ 'Assign the ticket to a member: username, email, user-id, or orgid:username (defaults to creator).'
647
+ },
631
648
  agent: { type: 'string' },
632
649
  skip_file_change_check: { type: 'boolean' }
633
650
  },
@@ -643,6 +660,7 @@ const tools = [
643
660
  'acceptance-criteria': args.acceptance_criteria,
644
661
  'available-tools': args.available_tools,
645
662
  delegate: args.delegate,
663
+ 'assigned-to': args.assigned_to,
646
664
  agent: args.agent,
647
665
  'skip-file-change-check': args.skip_file_change_check
648
666
  }),
@@ -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:
@@ -118,6 +118,7 @@ When creating tickets from within a repository:
118
118
  - Create multiple tickets when each prompt represents a different feature or goal.
119
119
  - Add objectives to the same ticket when each prompt is a sequential step toward the same feature or goal; use `ovld protocol add-objectives --ticket-id <ticket_id> --objectives-json '[{"objective":"..."}]'`.
120
120
  - `create`, `prompt`, and `record-work` require `--objectives-json` or `--objectives-file` with an ordered array of `{ "objective": "...", "title": "...", "autoAdvance": true }` objects. A single objective is just an array with one item.
121
+ - `create`, `prompt`, `create-ticket`, and `record-work` accept `--assigned-to <member>` to set the ticket's human owner. Accepts a username, an email, a user-id UUID, or the `orgid:username` member ID. When omitted, the assignee defaults to the ticket creator.
121
122
 
122
123
  ```bash
123
124
  ovld protocol create --agent codex --objectives-json '[{"objective":"Capture follow-up work from this repository"}]'
@@ -131,6 +132,17 @@ ovld protocol prompt --agent codex --objectives-json '[{"objective":"Implement f
131
132
  ovld protocol add-objectives --ticket-id 1:899 --objectives-json '[{"objective":"Implement the API"},{"objective":"Add CLI docs"}]'
132
133
  ```
133
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
+
134
146
  To inspect project resolution explicitly:
135
147
 
136
148
  ```bash