@adhdev/daemon-core 0.9.82-rc.185 → 0.9.82-rc.187

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.
@@ -36,6 +36,14 @@ import {
36
36
  type InteractivePromptResponse,
37
37
  } from '../types/interactive-prompt.js';
38
38
 
39
+ function stripAnsi(text: string): string {
40
+ // eslint-disable-next-line no-control-regex
41
+ return String(text || '')
42
+ .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
43
+ .replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, '')
44
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
45
+ }
46
+
39
47
  export class SpecCliAdapter implements CliAdapter {
40
48
  readonly cliType: string;
41
49
  readonly cliName: string;
@@ -129,15 +137,16 @@ export class SpecCliAdapter implements CliAdapter {
129
137
  }
130
138
 
131
139
  getStatus(): CliAdapterStatus {
132
- if (this.exited) return { status: 'stopped', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt };
133
- if (!this.spawned) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt };
140
+ const sessionFields = this.providerSessionId ? { providerSessionId: this.providerSessionId } : {};
141
+ if (this.exited) return { status: 'stopped', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
142
+ if (!this.spawned) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
134
143
 
135
144
  // Refresh native history lazily — the watch_path is cheap to stat,
136
145
  // but parsing a full session.jsonl every call would be wasteful.
137
146
  this.maybeRefreshNativeHistory();
138
147
 
139
148
  const state = this.latestState;
140
- if (!state) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt };
149
+ if (!state) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
141
150
 
142
151
  const modal = this.latestModal;
143
152
  const lc = state.id.toLowerCase();
@@ -150,12 +159,13 @@ export class SpecCliAdapter implements CliAdapter {
150
159
  buttons: modal.buttons.map(b => b.label),
151
160
  },
152
161
  activeInteractivePrompt: this.activeInteractivePrompt,
162
+ ...sessionFields,
153
163
  };
154
164
  }
155
165
  if (lc === 'busy' || lc === 'generating') {
156
- return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt };
166
+ return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
157
167
  }
158
- return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt };
168
+ return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
159
169
  }
160
170
 
161
171
  private maybeRefreshNativeHistory(): void {
@@ -165,10 +175,13 @@ export class SpecCliAdapter implements CliAdapter {
165
175
  }
166
176
 
167
177
  getScriptParsedStatus(): unknown {
178
+ const providerSessionId = this.extractProviderSessionIdFromScreen();
179
+ if (providerSessionId) this.providerSessionId = providerSessionId;
168
180
  const status = this.getStatus();
169
181
  return {
170
182
  ...status,
171
183
  messages: this.readClaudeScreenAssistantMessages(),
184
+ ...(this.providerSessionId ? { providerSessionId: this.providerSessionId } : {}),
172
185
  };
173
186
  }
174
187
 
@@ -345,6 +358,7 @@ export class SpecCliAdapter implements CliAdapter {
345
358
  displayName: this.spec.name,
346
359
  spawnedAtMs: this.spawnedAtMs,
347
360
  spawnedEnv: this.spawnedEnv,
361
+ ...(this.providerSessionId ? { providerSessionId: this.providerSessionId } : {}),
348
362
  };
349
363
  }
350
364
  updateRuntimeMeta(meta?: Record<string, unknown>): void {
@@ -419,6 +433,19 @@ export class SpecCliAdapter implements CliAdapter {
419
433
  }
420
434
  }
421
435
 
436
+ private extractProviderSessionIdFromScreen(): string | undefined {
437
+ if (this.cliType !== 'codex-cli') return this.providerSessionId;
438
+ let screenText = '';
439
+ try {
440
+ screenText = this.driver.snapshot();
441
+ } catch {
442
+ return this.providerSessionId;
443
+ }
444
+ const clean = stripAnsi(screenText);
445
+ const match = clean.match(/(?:gpt-|o\d|codex-)[^\n·]*·[^\n·]*·\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
446
+ return match?.[1] || this.providerSessionId;
447
+ }
448
+
422
449
  private readClaudeScreenAssistantMessages(): ChatMessage[] {
423
450
  if (this.cliType !== 'claude-cli') return [];
424
451
  let screenText = '';
@@ -231,8 +231,18 @@ export function evaluate(spec: CliSpec, screenText: string): SpecEvaluation {
231
231
  for (const st of spec.states) {
232
232
  const { matched, title } = matchState(st, sections, screenText, trace);
233
233
  if (!matched) continue;
234
+ const extractedModal = extractModal(st, sections, screenText, title, trace);
235
+ // If the state declares modal_buttons but extraction failed (button
236
+ // count below min_count, or text-was-mistaken-for-modal), do not
237
+ // promote the state. Otherwise we would surface a phantom approval
238
+ // built from arbitrary screen text — see claude-cli numbered-list
239
+ // false-positive on 2026-06-07.
240
+ if (st.modal_buttons && !extractedModal) {
241
+ trace.push({ kind: 'state_skip', text: `state[${st.id}] matched but modal_buttons extraction failed — not promoting` });
242
+ continue;
243
+ }
234
244
  activeState = { id: st.id, label: st.label, title };
235
- modal = extractModal(st, sections, screenText, title, trace);
245
+ modal = extractedModal;
236
246
  break;
237
247
  }
238
248
 
@@ -58,6 +58,7 @@ export interface NativeHistoryMessage {
58
58
  content: string;
59
59
  receivedAt: number;
60
60
  kind?: string;
61
+ workspace?: string;
61
62
  }
62
63
 
63
64
  export interface NativeHistoryResult {
@@ -66,6 +67,7 @@ export interface NativeHistoryResult {
66
67
  sourcePath: string;
67
68
  sourceMtimeMs: number;
68
69
  nativeHistoryCoverage?: 'full' | 'partial' | 'best-effort';
70
+ workspace?: string;
69
71
  }
70
72
 
71
73
  const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
@@ -87,6 +89,7 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
87
89
 
88
90
  const windowMs = typeof src.recent_window_ms === 'number' ? src.recent_window_ms : 5 * 60_000;
89
91
  const filePat = src.file_pattern ? globToRegex(src.file_pattern) : /.*\.jsonl$/;
92
+ const requestedSessionId = readRequestedSessionId(input);
90
93
 
91
94
  // path can be:
92
95
  // - a concrete file → used as-is
@@ -112,7 +115,8 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
112
115
  const workspaceHint = typeof input.workspace === 'string' && input.workspace.trim() ? input.workspace.trim() : '';
113
116
  let sourcePath: string | null = null;
114
117
  if (resolved.includes('*')) {
115
- sourcePath = pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
118
+ sourcePath = pickExactSessionFileAcrossGlob(resolved, filePat, requestedSessionId)
119
+ || pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
116
120
  || newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
117
121
  } else {
118
122
  let stat: fs.Stats | null = null;
@@ -120,18 +124,18 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
120
124
  if (stat && stat.isFile()) {
121
125
  sourcePath = resolved;
122
126
  } else if (stat && stat.isDirectory()) {
123
- sourcePath = pickSessionBoundFile(resolved, filePat, windowMs, sessionFloor, workspaceHint)
124
- || newestRecentFile(resolved, filePat, windowMs, sessionFloor);
127
+ sourcePath = pickExactSessionFile(resolved, filePat, requestedSessionId)
128
+ || (requestedSessionId ? null : pickSessionBoundFile(resolved, filePat, windowMs, sessionFloor, workspaceHint))
129
+ || (requestedSessionId ? null : newestRecentFile(resolved, filePat, windowMs, sessionFloor));
125
130
  }
126
131
  // Date-templated directories (e.g. ~/.codex/sessions/{yyyy}/{mm}/{dd})
127
- // expand to today's UTC dir. A session started before UTC midnight
128
- // keeps appending to its previous-day file, so today's dir never
129
- // contains the live transcript. When the templated dir is empty
130
- // or doesn't exist yet, walk the last 3 UTC days at the same depth
131
- // and pick the newest matching rollout across all of them.
132
+ // can drift from the provider's chosen calendar day because CLIs
133
+ // disagree on local-vs-UTC date buckets. Search nearby date dirs
134
+ // before falling back to non-exact matching.
132
135
  if (!sourcePath && hasDateTemplateSegment(src.path)) {
133
- sourcePath = pickSessionBoundFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor, workspaceHint)
134
- || newestRecentFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor);
136
+ sourcePath = pickExactSessionFileAcrossDateWindow(src.path, input, filePat, requestedSessionId)
137
+ || (requestedSessionId ? null : pickSessionBoundFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor, workspaceHint))
138
+ || (requestedSessionId ? null : newestRecentFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor));
135
139
  }
136
140
  }
137
141
  if (!sourcePath) return null;
@@ -139,6 +143,7 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
139
143
  const mtime = safeMtimeMs(sourcePath);
140
144
  const lines = readJsonlLines(sourcePath);
141
145
  if (lines.length === 0) return null;
146
+ const transcriptWorkspace = readSessionMetaWorkspace(lines);
142
147
 
143
148
  // session id: filename uuid or extracted from first record
144
149
  let providerSessionId: string | undefined;
@@ -150,7 +155,7 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
150
155
  if (m) providerSessionId = m[1];
151
156
  }
152
157
 
153
- const requested = input.providerSessionId || '';
158
+ const requested = requestedSessionId || '';
154
159
  if (requested && providerSessionId && providerSessionId !== requested) return null;
155
160
 
156
161
  const filter = src.message_filter ? compileWhere(src.message_filter.where) : null;
@@ -159,7 +164,10 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
159
164
  const rec = lines[i];
160
165
  if (filter && !filter(rec)) continue;
161
166
  const msg = projectMessage(rec, src.message_map, i, lines.length, mtime);
162
- if (msg) messages.push(msg);
167
+ if (msg) {
168
+ if (transcriptWorkspace) msg.workspace = transcriptWorkspace;
169
+ messages.push(msg);
170
+ }
163
171
  }
164
172
  if (messages.length === 0) return null;
165
173
 
@@ -169,9 +177,19 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
169
177
  sourcePath,
170
178
  sourceMtimeMs: mtime,
171
179
  nativeHistoryCoverage: 'full',
180
+ workspace: transcriptWorkspace,
172
181
  };
173
182
  }
174
183
 
184
+ function readSessionMetaWorkspace(lines: any[]): string | undefined {
185
+ for (const record of lines.slice(0, 5)) {
186
+ if (String(record?.type ?? '') !== 'session_meta') continue;
187
+ const cwd = typeof record?.payload?.cwd === 'string' ? record.payload.cwd.trim() : '';
188
+ if (cwd) return cwd;
189
+ }
190
+ return undefined;
191
+ }
192
+
175
193
  function readJsonlLines(p: string): any[] {
176
194
  let text: string;
177
195
  try { text = fs.readFileSync(p, 'utf8'); } catch { return []; }
@@ -292,9 +310,9 @@ function expandPath(template: string, input: NativeHistoryInput): string | null
292
310
  cwd: workspaceResolved,
293
311
  cwd_dashed: workspaceResolved.replace(/\//g, '-'),
294
312
  session_id: input.providerSessionId || input.sessionId || input.historySessionId || '',
295
- yyyy: String(now.getUTCFullYear()),
296
- mm: String(now.getUTCMonth() + 1).padStart(2, '0'),
297
- dd: String(now.getUTCDate()).padStart(2, '0'),
313
+ yyyy: String(now.getFullYear()),
314
+ mm: String(now.getMonth() + 1).padStart(2, '0'),
315
+ dd: String(now.getDate()).padStart(2, '0'),
298
316
  };
299
317
  // Replace {var}. If a referenced variable is empty (e.g. session_id
300
318
  // before the agent has allocated one), return null so the caller
@@ -393,12 +411,10 @@ function hasDateTemplateSegment(template: string): boolean {
393
411
  }
394
412
 
395
413
  /**
396
- * Walk the last N UTC days of a date-templated path (e.g.
414
+ * Walk nearby local calendar days of a date-templated path (e.g.
397
415
  * `~/.codex/sessions/{yyyy}/{mm}/{dd}`) and return the newest matching
398
- * file across all of them. Codex sessions started just before UTC
399
- * midnight keep writing to their original day's file even after the
400
- * date rolls over, so today's expanded dir alone misses the live
401
- * transcript.
416
+ * file across all of them. Providers differ on local-vs-UTC date buckets,
417
+ * so today's expanded dir alone can miss a live transcript.
402
418
  */
403
419
  function newestRecentFileAcrossDateWindow(
404
420
  template: string,
@@ -409,8 +425,8 @@ function newestRecentFileAcrossDateWindow(
409
425
  ): string | null {
410
426
  const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs);
411
427
  let best: { p: string; mtime: number } | null = null;
412
- for (let dayOffset = 0; dayOffset < 3; dayOffset += 1) {
413
- const dayMs = Date.now() - dayOffset * 24 * 60 * 60 * 1000;
428
+ for (const dayOffset of [0, -1, 1, -2, 2]) {
429
+ const dayMs = Date.now() + dayOffset * 24 * 60 * 60 * 1000;
414
430
  const dayInput: NativeHistoryInput = { ...input, sessionStartedAtMs: sessionFloorMs };
415
431
  const resolved = expandPathForDate(template, dayInput, new Date(dayMs));
416
432
  if (!resolved) continue;
@@ -448,9 +464,9 @@ function expandPathForDate(template: string, input: NativeHistoryInput, day: Dat
448
464
  cwd: workspaceResolved,
449
465
  cwd_dashed: workspaceResolved.replace(/\//g, '-'),
450
466
  session_id: input.providerSessionId || input.sessionId || input.historySessionId || '',
451
- yyyy: String(day.getUTCFullYear()),
452
- mm: String(day.getUTCMonth() + 1).padStart(2, '0'),
453
- dd: String(day.getUTCDate()).padStart(2, '0'),
467
+ yyyy: String(day.getFullYear()),
468
+ mm: String(day.getMonth() + 1).padStart(2, '0'),
469
+ dd: String(day.getDate()).padStart(2, '0'),
454
470
  };
455
471
  let missing = false;
456
472
  out = out.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_m, name) => {
@@ -481,6 +497,56 @@ function safeMtimeMs(p: string): number {
481
497
  try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
482
498
  }
483
499
 
500
+ function readRequestedSessionId(input: NativeHistoryInput): string {
501
+ const raw = input.providerSessionId || input.sessionId || input.historySessionId || '';
502
+ const value = typeof raw === 'string' ? raw.trim() : '';
503
+ return UUID_RE.test(value) ? value : '';
504
+ }
505
+
506
+ function filenameUuid(filePath: string): string {
507
+ const match = path.basename(filePath).match(UUID_RE);
508
+ return match?.[1] || '';
509
+ }
510
+
511
+ function pickExactSessionFile(dir: string, pattern: RegExp, requestedSessionId: string): string | null {
512
+ if (!requestedSessionId) return null;
513
+ const files = listMatchingFiles(dir, pattern)
514
+ .filter(p => filenameUuid(p).toLowerCase() === requestedSessionId.toLowerCase())
515
+ .sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a));
516
+ return files[0] || null;
517
+ }
518
+
519
+ function pickExactSessionFileAcrossGlob(template: string, pattern: RegExp, requestedSessionId: string): string | null {
520
+ if (!requestedSessionId) return null;
521
+ const dirs = expandDirGlob(template);
522
+ const matches: string[] = [];
523
+ for (const d of dirs) {
524
+ const found = pickExactSessionFile(d, pattern, requestedSessionId);
525
+ if (found) matches.push(found);
526
+ }
527
+ matches.sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a));
528
+ return matches[0] || null;
529
+ }
530
+
531
+ function pickExactSessionFileAcrossDateWindow(
532
+ template: string,
533
+ input: NativeHistoryInput,
534
+ pattern: RegExp,
535
+ requestedSessionId: string,
536
+ ): string | null {
537
+ if (!requestedSessionId) return null;
538
+ const matches: string[] = [];
539
+ for (const dayOffset of [0, -1, 1, -2, 2]) {
540
+ const dayMs = Date.now() + dayOffset * 24 * 60 * 60 * 1000;
541
+ const resolved = expandPathForDate(template, input, new Date(dayMs));
542
+ if (!resolved) continue;
543
+ const found = pickExactSessionFile(resolved, pattern, requestedSessionId);
544
+ if (found) matches.push(found);
545
+ }
546
+ matches.sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a));
547
+ return matches[0] || null;
548
+ }
549
+
484
550
  // ────────────────────────────────────────────────────────────────────────────
485
551
  // Per-session rollout binding
486
552
  //
@@ -624,8 +690,8 @@ function pickSessionBoundFileAcrossDateWindow(
624
690
  if (!sessionFloorMs || !workspaceHint) return null;
625
691
  const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs - SPAWN_BIND_GRACE_MS);
626
692
  const files: string[] = [];
627
- for (let dayOffset = 0; dayOffset < 3; dayOffset += 1) {
628
- const dayMs = sessionFloorMs - dayOffset * 24 * 60 * 60 * 1000;
693
+ for (const dayOffset of [0, -1, 1, -2, 2]) {
694
+ const dayMs = sessionFloorMs + dayOffset * 24 * 60 * 60 * 1000;
629
695
  const dayInput: NativeHistoryInput = { ...input, sessionStartedAtMs: sessionFloorMs };
630
696
  const resolved = expandPathForDate(template, dayInput, new Date(dayMs));
631
697
  if (!resolved) continue;
@@ -217,7 +217,19 @@ function parseClaudeHeaderlessInteractiveTuiQuestion(page: ClaudeInteractiveTuiP
217
217
  let question = '';
218
218
  for (let i = firstOptionIndex - 1; i >= 0; i -= 1) {
219
219
  const candidate = lines[i].trim();
220
- if (!candidate || /^─+$/.test(candidate) || /^[☐☒]\s+/.test(candidate)) continue;
220
+ if (!candidate || /^─+$/.test(candidate)) continue;
221
+ // Standalone ☐/☒ markers (decorative section dividers in the headered
222
+ // variant) are not the question — keep skipping them.
223
+ if (/^[☐☒]\s*$/.test(candidate)) continue;
224
+ // The headerless variant introduced in claude-cli >=2.1 prefixes the
225
+ // actual question with `☐ ` (e.g. "☐ RPS R1 1라운드 — …"). Previously
226
+ // we skipped any ☐/☒ line and returned null, never opening the picker.
227
+ // Strip the marker so the dashboard label matches the on-screen text.
228
+ const markerMatch = candidate.match(/^[☐☒]\s+(.+)$/);
229
+ if (markerMatch) {
230
+ question = markerMatch[1].trim();
231
+ break;
232
+ }
221
233
  question = candidate;
222
234
  break;
223
235
  }