@adhdev/daemon-core 0.9.82-rc.530 → 0.9.82-rc.532

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.
@@ -39,6 +39,20 @@ export interface TranscriptPtySpec {
39
39
  chromePatterns?: ChromePatternSpec[];
40
40
  stripLeadingChrome?: boolean;
41
41
  scope?: 'screen' | 'buffer' | 'tail';
42
+ /**
43
+ * Optional SGR background-color codes that mark a USER turn. Some TUIs
44
+ * (cursor-agent) render the user's submitted message and the composer echo
45
+ * inside a colored box but render the assistant answer as a plain line with
46
+ * no background. After ANSI stripping both look like bare 2-space lines, so
47
+ * a bg-less assistant answer and a boxed user echo are indistinguishable by
48
+ * text prefix alone — the user echo then leaks into an assistant bubble.
49
+ * When set, a raw line whose ANSI carries any of these background SGRs (e.g.
50
+ * "48;5;233") is classified as a user turn regardless of its stripped text,
51
+ * so the plain assistant answer is the only assistant bubble left. Matched
52
+ * against the raw pre-strip line; the visible text is still ANSI-stripped
53
+ * for the bubble content.
54
+ */
55
+ userBackgroundSgr?: string[];
42
56
  }
43
57
  export interface SessionIdExtractionSpec {
44
58
  $schema?: 'adhdev:tui/session-id-extraction@1';
@@ -114,6 +114,19 @@ export interface NativeHistoryMessageMap {
114
114
  content_unwrap?: string[];
115
115
  timestamp_ms?: string;
116
116
  kind?: string;
117
+ /**
118
+ * Optional jsonpath-lite path to a per-message workspace/cwd value. sqlite
119
+ * sources have no `session_meta` record to carry the workspace (that's a
120
+ * jsonl-only convention), so a native-source provider whose store keeps the
121
+ * session directory as a column (e.g. opencode's `session.directory`) can
122
+ * SELECT it into each message row and map it here. The read pipeline's
123
+ * `hasSafeNativeHistoryMapping` guard requires each message to declare a
124
+ * workspace when the read is workspace-scoped (no provider session id was
125
+ * captured from the TUI); without it a workspace-only lookup fails closed
126
+ * and every assistant bubble is dropped. Absent → messages carry no
127
+ * workspace (jsonl still fills it from session_meta).
128
+ */
129
+ workspace?: string;
117
130
  /**
118
131
  * Declarative tool-bubble extraction. Without it the executor only emits
119
132
  * the text-bearing parts of each record, so a turn that is purely a tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.530",
3
+ "version": "0.9.82-rc.532",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.530",
51
- "@adhdev/session-host-core": "0.9.82-rc.530",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.532",
51
+ "@adhdev/session-host-core": "0.9.82-rc.532",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -46,6 +46,20 @@ export interface TranscriptPtySpec {
46
46
  chromePatterns?: ChromePatternSpec[]
47
47
  stripLeadingChrome?: boolean
48
48
  scope?: 'screen' | 'buffer' | 'tail'
49
+ /**
50
+ * Optional SGR background-color codes that mark a USER turn. Some TUIs
51
+ * (cursor-agent) render the user's submitted message and the composer echo
52
+ * inside a colored box but render the assistant answer as a plain line with
53
+ * no background. After ANSI stripping both look like bare 2-space lines, so
54
+ * a bg-less assistant answer and a boxed user echo are indistinguishable by
55
+ * text prefix alone — the user echo then leaks into an assistant bubble.
56
+ * When set, a raw line whose ANSI carries any of these background SGRs (e.g.
57
+ * "48;5;233") is classified as a user turn regardless of its stripped text,
58
+ * so the plain assistant answer is the only assistant bubble left. Matched
59
+ * against the raw pre-strip line; the visible text is still ANSI-stripped
60
+ * for the bubble content.
61
+ */
62
+ userBackgroundSgr?: string[]
49
63
  }
50
64
 
51
65
  export interface SessionIdExtractionSpec {
@@ -107,6 +121,22 @@ function splitLines(text: string): string[] {
107
121
  .map(l => l.replace(/\s+$/, ''))
108
122
  }
109
123
 
124
+ /**
125
+ * Split into raw (pre-strip) lines paired with their stripped visible text so
126
+ * per-line ANSI (e.g. a user-turn background SGR) can be inspected before the
127
+ * color is discarded. The stripped column matches splitLines() line for line:
128
+ * same newline split, same trailing-space trim — only the ANSI is retained on
129
+ * the `raw` side. Splitting on newline keeps any leading SGR of a line attached
130
+ * to that line (cursor-agent writes `<bg-sgr> <text>` on one raw line), so a
131
+ * per-line background test is exact.
132
+ */
133
+ function splitRawLines(text: string): Array<{ raw: string; text: string }> {
134
+ return String(text || '')
135
+ .split(/\r?\n/)
136
+ // eslint-disable-next-line no-control-regex
137
+ .map(rawLine => ({ raw: rawLine, text: stripAnsi(rawLine).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').replace(/\s+$/, '') }))
138
+ }
139
+
110
140
  function pickInputText(input: any, scope: TranscriptPtySpec['scope']): string {
111
141
  if (!input) return ''
112
142
  if (scope === 'screen') return String(input.screenText || input.screen?.text || '')
@@ -187,6 +217,19 @@ export function buildParseSessionFromTui(spec: ParseSessionTuiSpec): (input: any
187
217
  const requireIndentForContinuation = spec.transcriptPty.continuationLine?.indented ?? false
188
218
  const stripLeadingChrome = spec.transcriptPty.stripLeadingChrome ?? true
189
219
  const scope = spec.transcriptPty.scope ?? 'buffer'
220
+ // Background-SGR user-turn markers. A raw line whose ANSI carries one of
221
+ // these `48;5;NNN`-style background codes is a user turn even when its
222
+ // stripped text is a bare line the assistantPrefix would otherwise grab.
223
+ const userBgList = Array.isArray(spec.transcriptPty.userBackgroundSgr)
224
+ ? spec.transcriptPty.userBackgroundSgr.map(s => String(s).trim()).filter(Boolean)
225
+ : []
226
+ const userBgRes = userBgList.map(code => {
227
+ // Match the code inside an SGR run: `\x1b[<...>48;5;233<...>m`. The code
228
+ // (e.g. "48;5;233") appears somewhere in the `;`-separated parameter list
229
+ // terminated by `m`. Escape regex-special chars in the code first.
230
+ const esc = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
231
+ return new RegExp(`\\x1b\\[[0-9;]*${esc}[0-9;]*m`)
232
+ })
190
233
 
191
234
  // Compose detect + approval helpers if their inputs are present.
192
235
  const detectStatus = (spec.spinner || spec.settledPrompt || spec.modal || spec.dispatchOrder)
@@ -215,13 +258,16 @@ export function buildParseSessionFromTui(spec: ParseSessionTuiSpec): (input: any
215
258
  const modal = parseApproval(input as any)
216
259
 
217
260
  const text = pickInputText(input, scope)
218
- const lines = splitLines(text)
261
+ // When a bg-SGR user marker is configured we need the raw (pre-strip)
262
+ // line to test the background color; otherwise the cheaper stripped
263
+ // split is enough. Both yield the same stripped `text` per line.
264
+ const rawLines = userBgRes.length > 0 ? splitRawLines(text) : splitLines(text).map(t => ({ raw: '', text: t }))
219
265
 
220
266
  const messages: SynthesizedMessage[] = []
221
267
  let seenFirstRoleLine = !stripLeadingChrome
222
268
 
223
- for (const raw of lines) {
224
- const line = raw
269
+ for (const entry of rawLines) {
270
+ const line = entry.text
225
271
  if (line.trim() === '') {
226
272
  // Blank line: ends streaming continuation but doesn't add a message.
227
273
  continue
@@ -234,6 +280,19 @@ export function buildParseSessionFromTui(spec: ParseSessionTuiSpec): (input: any
234
280
  }
235
281
  if (isChrome) continue
236
282
 
283
+ // Background-SGR user classification. Checked before prefix matching
284
+ // so a user turn rendered as a bg-boxed plain line (no distinctive
285
+ // glyph — cursor-agent) is attributed to the user instead of being
286
+ // grabbed by a permissive assistantPrefix. The visible text is the
287
+ // ANSI-stripped `line`.
288
+ if (userBgRes.length > 0 && entry.raw && userBgRes.some(re => re.test(entry.raw))) {
289
+ seenFirstRoleLine = true
290
+ // Strip a leading composer glyph (e.g. "→ ") the boxed echo may carry.
291
+ const content = line.replace(/^\s*[→>›❯]\s*/, '').trim()
292
+ if (content) messages.push({ role: 'user', kind: 'standard', content })
293
+ continue
294
+ }
295
+
237
296
  // Role detection
238
297
  const userMatch = userRe ? line.match(userRe) : null
239
298
  const toolMatch = toolRe ? line.match(toolRe) : null
@@ -78,6 +78,11 @@
78
78
  "enum": ["screen", "buffer", "tail"],
79
79
  "default": "buffer",
80
80
  "description": "Which input field to parse: rendered screen text, full buffer (default), or tail string. Most providers use buffer because the screen alone loses scrollback."
81
+ },
82
+ "userBackgroundSgr": {
83
+ "type": "array",
84
+ "items": { "type": "string" },
85
+ "description": "Optional SGR background-color codes (e.g. \"48;5;233\") that mark a USER turn. Some TUIs (cursor-agent) render the user's submitted message and composer echo inside a colored box while rendering the assistant answer as a plain no-background line. After ANSI stripping both are bare lines, so a permissive assistantPrefix grabs the user echo as an assistant bubble. When set, a raw line whose ANSI carries any of these background codes is classified as a user turn regardless of its stripped text, leaving the plain assistant answer as the only assistant bubble. Checked against the raw pre-strip line before prefix matching."
81
86
  }
82
87
  }
83
88
  }
@@ -357,11 +357,28 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
357
357
  const sessionFloorSeconds = typeof input.sessionStartedAtMs === 'number'
358
358
  ? Math.floor(input.sessionStartedAtMs / 1000)
359
359
  : 0;
360
+ // Workspace the daemon spawned this CLI in. A store that keeps
361
+ // the session directory as a column (opencode's
362
+ // `session.directory`) can scope the newest-session pick to this
363
+ // workspace so two concurrent sessions in different workspaces
364
+ // don't cross-bind — the time floor alone can't disambiguate
365
+ // when the OTHER workspace's session was touched more recently.
366
+ const workspaceHint = typeof input.workspace === 'string' ? input.workspace : '';
360
367
  const stmt = db.prepare(src.session_query);
368
+ // Binding tiers, tried in order (better-sqlite3 throws when the
369
+ // statement declares params the bind object/args don't satisfy,
370
+ // so each tier is guarded):
371
+ // 1. named { floor, workspace } — spec references @floor/@workspace
372
+ // 2. positional (floor) — legacy single-`?` floor specs
373
+ // 3. no-arg — specs with no bound params
361
374
  try {
362
- sessionRow = stmt.get(sessionFloorSeconds);
375
+ sessionRow = stmt.get({ floor: sessionFloorSeconds, workspace: workspaceHint });
363
376
  } catch {
364
- sessionRow = stmt.get();
377
+ try {
378
+ sessionRow = stmt.get(sessionFloorSeconds);
379
+ } catch {
380
+ sessionRow = stmt.get();
381
+ }
365
382
  }
366
383
  } catch { return ''; }
367
384
  if (!sessionRow) return '';
@@ -406,12 +423,18 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
406
423
  }
407
424
  if (messages.length === 0) return null;
408
425
 
426
+ // Surface the workspace at the result level too (mirrors the jsonl
427
+ // session_meta path) so callers that read result.workspace — not just
428
+ // per-message workspace — see the session directory.
429
+ const resultWorkspace = messages.find(m => m.workspace)?.workspace;
430
+
409
431
  return {
410
432
  messages,
411
433
  providerSessionId: sessionId,
412
434
  sourcePath: resolved,
413
435
  sourceMtimeMs: mtime,
414
436
  nativeHistoryCoverage: 'full',
437
+ ...(resultWorkspace ? { workspace: resultWorkspace } : {}),
415
438
  };
416
439
  } finally {
417
440
  try { db.close(); } catch { /* ignore */ }
@@ -1015,9 +1038,17 @@ function projectMessages(record: any, map: NativeHistoryMessageMap, index: numbe
1015
1038
  }
1016
1039
  }
1017
1040
 
1041
+ // Per-message workspace: sqlite sources have no `session_meta` record to
1042
+ // carry the cwd (jsonl-only), so a spec can SELECT the session directory
1043
+ // into each row and map it here. The downstream hasSafeNativeHistoryMapping
1044
+ // guard needs it to accept a workspace-scoped read (no provider session id
1045
+ // captured from the TUI); without it every assistant bubble is dropped.
1046
+ const workspaceRaw = map.workspace ? jsonPathGet(record, map.workspace) : undefined;
1047
+ const workspace = typeof workspaceRaw === 'string' && workspaceRaw.trim() ? workspaceRaw.trim() : undefined;
1048
+
1018
1049
  const contentRaw = jsonPathGet(record, map.content);
1019
1050
  const content = cleanContent(stringifyContent(contentRaw), map);
1020
- if (content) out.push({ role, content, receivedAt, kind });
1051
+ if (content) out.push(workspace ? { role, content, receivedAt, kind, workspace } : { role, content, receivedAt, kind });
1021
1052
 
1022
1053
  // Block-nested tool bubbles are ordered just after the text bubble of the
1023
1054
  // same record by nudging receivedAt forward a millisecond per bubble, so a
@@ -135,6 +135,19 @@ export interface NativeHistoryMessageMap {
135
135
  content_unwrap?: string[];
136
136
  timestamp_ms?: string;
137
137
  kind?: string;
138
+ /**
139
+ * Optional jsonpath-lite path to a per-message workspace/cwd value. sqlite
140
+ * sources have no `session_meta` record to carry the workspace (that's a
141
+ * jsonl-only convention), so a native-source provider whose store keeps the
142
+ * session directory as a column (e.g. opencode's `session.directory`) can
143
+ * SELECT it into each message row and map it here. The read pipeline's
144
+ * `hasSafeNativeHistoryMapping` guard requires each message to declare a
145
+ * workspace when the read is workspace-scoped (no provider session id was
146
+ * captured from the TUI); without it a workspace-only lookup fails closed
147
+ * and every assistant bubble is dropped. Absent → messages carry no
148
+ * workspace (jsonl still fills it from session_meta).
149
+ */
150
+ workspace?: string;
138
151
  /**
139
152
  * Declarative tool-bubble extraction. Without it the executor only emits
140
153
  * the text-bearing parts of each record, so a turn that is purely a tool