@adhdev/daemon-core 0.9.82-rc.535 → 0.9.82-rc.537
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.
- package/dist/index.js +103 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +103 -6
- package/dist/index.mjs.map +1 -1
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +13 -0
- package/dist/providers/spec/types.d.ts +17 -0
- package/package.json +3 -3
- package/src/cli-adapters/provider-cli-adapter.ts +7 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +108 -1
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +22 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +1 -1
- package/src/providers/spec/native-history-executor.ts +50 -1
- package/src/providers/spec/types.ts +17 -0
|
@@ -51,6 +51,19 @@ export interface TranscriptPtySpec {
|
|
|
51
51
|
* so the plain assistant answer is the only assistant bubble left. Matched
|
|
52
52
|
* against the raw pre-strip line; the visible text is still ANSI-stripped
|
|
53
53
|
* for the bubble content.
|
|
54
|
+
*
|
|
55
|
+
* Value-agnostic mode: the sentinel "*" (or "48;*" / "bg") matches ANY
|
|
56
|
+
* background-color SGR — 256-color `48;5;<n>` OR truecolor `48;2;<r>;<g>;<b>`
|
|
57
|
+
* — instead of an allowlist of exact color numbers. This is the robust form:
|
|
58
|
+
* the earlier allowlist bug recurred every time a TUI changed its exact box
|
|
59
|
+
* color (cursor `48;5;233` guess, opencode rc.531), because a hard-coded
|
|
60
|
+
* color that no longer matched let the boxed user echo fall through to the
|
|
61
|
+
* permissive assistantPrefix. The sentinel keys off the *presence* of a
|
|
62
|
+
* background-color param group — the structural signal of a boxed turn —
|
|
63
|
+
* so a genuine assistant line (no background SGR) is still classified
|
|
64
|
+
* assistant. Chrome patterns are filtered BEFORE this check, so bg-colored
|
|
65
|
+
* chrome (e.g. the "→ Add a follow-up" composer footer) is already excluded
|
|
66
|
+
* and does not become a spurious user bubble.
|
|
54
67
|
*/
|
|
55
68
|
userBackgroundSgr?: string[];
|
|
56
69
|
}
|
|
@@ -76,6 +76,23 @@ export interface NativeHistoryJsonlSource {
|
|
|
76
76
|
message_filter?: {
|
|
77
77
|
where: string;
|
|
78
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* Fallback workspace attribution when the transcript carries no
|
|
81
|
+
* `session_meta` cwd record. Some stores (cursor-agent) do NOT write a
|
|
82
|
+
* session_meta line and keep the workspace only in the on-disk project-slug
|
|
83
|
+
* directory (`~/.cursor/projects/<slug>/…`), which is a lossy, sometimes
|
|
84
|
+
* truncated+hashed transform of the real path and cannot be reversed. When
|
|
85
|
+
* this flag is set and no session_meta workspace was found, the executor
|
|
86
|
+
* stamps `input.workspace` onto each message — but ONLY after confirming the
|
|
87
|
+
* resolved file actually lives under that workspace's project slug (a path
|
|
88
|
+
* segment matches the input workspace's cursor/claude slug, allowing for
|
|
89
|
+
* cursor's length-truncation). This closes the first-turn read gap (before a
|
|
90
|
+
* provider session id is pinned, the downstream hasSafeNativeHistoryMapping
|
|
91
|
+
* guard needs a per-message workspace) without risking cross-workspace
|
|
92
|
+
* aliasing: a slug mismatch leaves the workspace unset and the read fails
|
|
93
|
+
* closed as before.
|
|
94
|
+
*/
|
|
95
|
+
workspace_from_input?: boolean;
|
|
79
96
|
message_map: NativeHistoryMessageMap;
|
|
80
97
|
}
|
|
81
98
|
export interface NativeHistorySqliteSource {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.537",
|
|
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.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.537",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.537",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -356,6 +356,13 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
356
356
|
const currentScreenLooksIdle = /(?:^|\n|\r)\s*[❯›>]\s*(?:Try\s+["“][^\n\r"”]+["”])?\s*(?:\n|\r|$)/.test(screenText)
|
|
357
357
|
&& !activeScreenPattern.test(screenText);
|
|
358
358
|
if (staleSnapshotLooksActive && currentScreenLooksIdle) return screenText;
|
|
359
|
+
// When the current frame already resolves to a settled/idle prompt via the
|
|
360
|
+
// provider's own detector, do NOT graft an older snapshot onto it: the
|
|
361
|
+
// older frame can carry a dead modal box (e.g. cursor-agent's leftover
|
|
362
|
+
// "Workspace Trust Required" rows) that re-fires `waiting_approval` at the
|
|
363
|
+
// appended tail and wedges the turn in `generating`. A truly idle current
|
|
364
|
+
// screen needs no historical supplement.
|
|
365
|
+
if (this.runDetectStatus(screenText) === 'idle') return screenText;
|
|
359
366
|
if (currentSnapshot.length >= lastSnapshot.length) return screenText;
|
|
360
367
|
// Terminal screen reads can miss a just-rendered completed Hermes box while
|
|
361
368
|
// the normalized snapshot captured during output still has it. Feed both
|
|
@@ -227,6 +227,108 @@ function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
|
|
|
227
227
|
return false;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Index (line number) of the last line in `screenText` that carries a modal
|
|
232
|
+
* cue — question line, question variant, or a button-block label line — or -1
|
|
233
|
+
* if none. Used to detect a *stale* modal box: some CLIs (e.g. cursor-agent's
|
|
234
|
+
* "Workspace Trust Required" prompt) never clear their box rows after the user
|
|
235
|
+
* answers. The redraw that replaces the modal with the idle composer is shorter
|
|
236
|
+
* than the box, so the top modal rows linger in the terminal grid. Without a
|
|
237
|
+
* spatial check, the unscoped whole-screen `modalMatches` keeps firing
|
|
238
|
+
* `waiting_approval` forever and the session wedges in `starting` — the
|
|
239
|
+
* startup gate never releases because `detectStatus` never returns `idle`.
|
|
240
|
+
*/
|
|
241
|
+
function lastModalCueLine(spec: ModalSpec, screenText: string): number {
|
|
242
|
+
if (!screenText) return -1;
|
|
243
|
+
const lines = screenText.split('\n');
|
|
244
|
+
const question = compile(spec.questionPattern, spec.questionFlags ?? 'i');
|
|
245
|
+
const variants = (spec.questionVariants ?? []).map((v) => compile(v.regex, v.flags ?? 'i'));
|
|
246
|
+
const buttonFlags = spec.buttonFlags && spec.buttonFlags.includes('m')
|
|
247
|
+
? spec.buttonFlags
|
|
248
|
+
: `${spec.buttonFlags ?? ''}m`;
|
|
249
|
+
const buttonRe = compile(spec.buttonPattern, buttonFlags);
|
|
250
|
+
let last = -1;
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
const line = lines[i];
|
|
253
|
+
question.lastIndex = 0;
|
|
254
|
+
if (question.test(line)) { last = i; continue; }
|
|
255
|
+
if (variants.some((re) => { re.lastIndex = 0; return re.test(line); })) { last = i; continue; }
|
|
256
|
+
buttonRe.lastIndex = 0;
|
|
257
|
+
if (buttonRe.test(line)) { last = i; continue; }
|
|
258
|
+
}
|
|
259
|
+
return last;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* True when the modal cue is *stale* — a leftover box the CLI failed to clear —
|
|
264
|
+
* because the live idle composer has repainted BELOW it.
|
|
265
|
+
*
|
|
266
|
+
* The discriminator is spatial, so a live modal (whose own selection cursor /
|
|
267
|
+
* button block can incidentally match a settled-prompt regex) is NOT mistaken
|
|
268
|
+
* for stale:
|
|
269
|
+
*
|
|
270
|
+
* - A settled-prompt cue must match strictly BELOW the last modal cue line.
|
|
271
|
+
* - AND at least one non-blank line between them is neither a modal cue nor
|
|
272
|
+
* part of the settled-prompt match itself (the separator prose).
|
|
273
|
+
*
|
|
274
|
+
* A live modal renders its question + button block FLUSH against its own
|
|
275
|
+
* composer/selection cursor (no intervening prose). A stale box, by contrast,
|
|
276
|
+
* has the CLI's welcome banner / follow-up hint / mode footer repainted between
|
|
277
|
+
* the leftover box rows and the live composer. So the discriminator is: a
|
|
278
|
+
* settled-prompt cue matches strictly BELOW the last modal cue line AND at least
|
|
279
|
+
* one non-blank, non-modal line separates them. That separator is exactly the
|
|
280
|
+
* content a live modal never has between its buttons and its cursor, and it is
|
|
281
|
+
* robust to the terminal-snapshot append that can shuffle the tail window.
|
|
282
|
+
*/
|
|
283
|
+
function modalSupersededBySettledPrompt(
|
|
284
|
+
modalSpec: ModalSpec,
|
|
285
|
+
settledSpec: SettledPromptSpec | undefined,
|
|
286
|
+
settled: ReturnType<typeof compileSettledPromptMatchers> | null,
|
|
287
|
+
input: CliStatusInput,
|
|
288
|
+
): boolean {
|
|
289
|
+
if (!settled || !settledSpec) return false;
|
|
290
|
+
if (settledSpec.scope === 'whole-screen') return false;
|
|
291
|
+
const screenText = input.screenText ?? '';
|
|
292
|
+
if (!screenText) return false;
|
|
293
|
+
const modalLine = lastModalCueLine(modalSpec, screenText);
|
|
294
|
+
if (modalLine < 0) return false;
|
|
295
|
+
const lines = screenText.split('\n');
|
|
296
|
+
const below = lines.slice(modalLine + 1);
|
|
297
|
+
if (below.length === 0) return false;
|
|
298
|
+
// A settled prompt (composer) must render somewhere below the modal box.
|
|
299
|
+
const belowText = below.join('\n');
|
|
300
|
+
if (!settled.prompt.test(belowText)) return false;
|
|
301
|
+
if (settled.footers.length > 0 && !settled.footers.every((f) => f.test(belowText))) return false;
|
|
302
|
+
// Require a real separator between the leftover box and the composer: a
|
|
303
|
+
// non-blank line that is neither a modal cue NOR part of the settled-prompt
|
|
304
|
+
// match itself. That separator is the CLI's welcome banner / follow-up hint /
|
|
305
|
+
// mode footer a stale box shows above the repainted composer. A live modal's
|
|
306
|
+
// selection cursor sits flush against its buttons with no such prose between
|
|
307
|
+
// them (and the cursor line, even if it matches the settled regex, is not a
|
|
308
|
+
// separator), so an active modal is never misread as stale.
|
|
309
|
+
const question = compile(modalSpec.questionPattern, modalSpec.questionFlags ?? 'i');
|
|
310
|
+
const variants = (modalSpec.questionVariants ?? []).map((v) => compile(v.regex, v.flags ?? 'i'));
|
|
311
|
+
const buttonFlags = modalSpec.buttonFlags && modalSpec.buttonFlags.includes('m')
|
|
312
|
+
? modalSpec.buttonFlags
|
|
313
|
+
: `${modalSpec.buttonFlags ?? ''}m`;
|
|
314
|
+
const buttonRe = compile(modalSpec.buttonPattern, buttonFlags);
|
|
315
|
+
const isModalCueLine = (line: string): boolean => {
|
|
316
|
+
question.lastIndex = 0;
|
|
317
|
+
if (question.test(line)) return true;
|
|
318
|
+
if (variants.some((re) => { re.lastIndex = 0; return re.test(line); })) return true;
|
|
319
|
+
buttonRe.lastIndex = 0;
|
|
320
|
+
return buttonRe.test(line);
|
|
321
|
+
};
|
|
322
|
+
// A single-line settled regex would let a lone match count as its own line;
|
|
323
|
+
// test each below-line against the prompt regex on that line alone.
|
|
324
|
+
const settledPromptLineRe = compile(settledSpec.regex, (settledSpec.flags ?? 'm').includes('m') ? (settledSpec.flags ?? 'm') : `${settledSpec.flags ?? ''}m`);
|
|
325
|
+
const isSettledLine = (line: string): boolean => {
|
|
326
|
+
settledPromptLineRe.lastIndex = 0;
|
|
327
|
+
return settledPromptLineRe.test(line);
|
|
328
|
+
};
|
|
329
|
+
return below.some((line) => line.trim() !== '' && !isModalCueLine(line) && !isSettledLine(line));
|
|
330
|
+
}
|
|
331
|
+
|
|
230
332
|
// ─── Public builder ────────────────────────────────────────────────────
|
|
231
333
|
|
|
232
334
|
const DEFAULT_ORDER: DispatchGroup[] = ['spinner', 'modal', 'settled-prompt'];
|
|
@@ -248,7 +350,12 @@ function evaluateGroup(
|
|
|
248
350
|
}
|
|
249
351
|
case 'modal': {
|
|
250
352
|
if (!spec.modal) return null;
|
|
251
|
-
|
|
353
|
+
if (!modalMatches(spec.modal, input)) return null;
|
|
354
|
+
// A modal cue with the live composer repainted below it (in the settled
|
|
355
|
+
// prompt's own tail scope) is a stale box the CLI failed to clear — yield
|
|
356
|
+
// so settled-prompt/idle can win.
|
|
357
|
+
if (modalSupersededBySettledPrompt(spec.modal, spec.settledPrompt, compiled.settled, input)) return null;
|
|
358
|
+
return 'waiting_approval';
|
|
252
359
|
}
|
|
253
360
|
case 'settled-prompt': {
|
|
254
361
|
if (!spec.settledPrompt || !compiled.settled) return null;
|
|
@@ -58,6 +58,19 @@ export interface TranscriptPtySpec {
|
|
|
58
58
|
* so the plain assistant answer is the only assistant bubble left. Matched
|
|
59
59
|
* against the raw pre-strip line; the visible text is still ANSI-stripped
|
|
60
60
|
* for the bubble content.
|
|
61
|
+
*
|
|
62
|
+
* Value-agnostic mode: the sentinel "*" (or "48;*" / "bg") matches ANY
|
|
63
|
+
* background-color SGR — 256-color `48;5;<n>` OR truecolor `48;2;<r>;<g>;<b>`
|
|
64
|
+
* — instead of an allowlist of exact color numbers. This is the robust form:
|
|
65
|
+
* the earlier allowlist bug recurred every time a TUI changed its exact box
|
|
66
|
+
* color (cursor `48;5;233` guess, opencode rc.531), because a hard-coded
|
|
67
|
+
* color that no longer matched let the boxed user echo fall through to the
|
|
68
|
+
* permissive assistantPrefix. The sentinel keys off the *presence* of a
|
|
69
|
+
* background-color param group — the structural signal of a boxed turn —
|
|
70
|
+
* so a genuine assistant line (no background SGR) is still classified
|
|
71
|
+
* assistant. Chrome patterns are filtered BEFORE this check, so bg-colored
|
|
72
|
+
* chrome (e.g. the "→ Add a follow-up" composer footer) is already excluded
|
|
73
|
+
* and does not become a spurious user bubble.
|
|
61
74
|
*/
|
|
62
75
|
userBackgroundSgr?: string[]
|
|
63
76
|
}
|
|
@@ -223,7 +236,16 @@ export function buildParseSessionFromTui(spec: ParseSessionTuiSpec): (input: any
|
|
|
223
236
|
const userBgList = Array.isArray(spec.transcriptPty.userBackgroundSgr)
|
|
224
237
|
? spec.transcriptPty.userBackgroundSgr.map(s => String(s).trim()).filter(Boolean)
|
|
225
238
|
: []
|
|
239
|
+
// Value-agnostic sentinel: "*" / "48;*" / "bg" matches ANY background-color
|
|
240
|
+
// SGR (256-color `48;5;<n>` OR truecolor `48;2;<r>;<g>;<b>`) as the boxed-user
|
|
241
|
+
// signal, instead of an allowlist of exact colors that breaks whenever a TUI
|
|
242
|
+
// changes its box color. The param group must sit inside an SGR run terminated
|
|
243
|
+
// by `m`; 3-digit color indices / channels are matched structurally.
|
|
244
|
+
const ANY_BG_SENTINELS = new Set(['*', '48;*', 'bg', '48'])
|
|
226
245
|
const userBgRes = userBgList.map(code => {
|
|
246
|
+
if (ANY_BG_SENTINELS.has(code.toLowerCase())) {
|
|
247
|
+
return new RegExp('\\x1b\\[[0-9;]*\\b48;(?:5;\\d{1,3}|2;\\d{1,3};\\d{1,3};\\d{1,3})[0-9;]*m')
|
|
248
|
+
}
|
|
227
249
|
// Match the code inside an SGR run: `\x1b[<...>48;5;233<...>m`. The code
|
|
228
250
|
// (e.g. "48;5;233") appears somewhere in the `;`-separated parameter list
|
|
229
251
|
// terminated by `m`. Escape regex-special chars in the code first.
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"userBackgroundSgr": {
|
|
83
83
|
"type": "array",
|
|
84
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."
|
|
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 (chrome patterns are filtered first, so bg-colored chrome is not misclassified as a user turn).\n\nValue-agnostic sentinel: the entry \"*\" (also \"48;*\" / \"bg\") matches ANY background-color SGR — 256-color `48;5;<n>` OR truecolor `48;2;<r>;<g>;<b>` — instead of an exact-color allowlist. Prefer \"*\": a hard-coded color re-breaks every time the TUI changes its box color (cursor \"48;5;233\", opencode), whereas \"*\" keys off the structural presence of a bg box. A genuine assistant line carries no background SGR, so it still classifies as assistant."
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -106,7 +106,12 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
|
|
|
106
106
|
const mtime = safeMtimeMs(sourcePath);
|
|
107
107
|
const lines = readJsonlLines(sourcePath);
|
|
108
108
|
if (lines.length === 0) return null;
|
|
109
|
-
|
|
109
|
+
// Prefer an in-transcript session_meta cwd; fall back to the input workspace
|
|
110
|
+
// only when the spec opts in AND the resolved file lives under that
|
|
111
|
+
// workspace's project slug (cursor-agent writes no session_meta and hides the
|
|
112
|
+
// workspace in the lossy on-disk slug — see workspace_from_input).
|
|
113
|
+
const transcriptWorkspace = readSessionMetaWorkspace(lines)
|
|
114
|
+
?? (src.workspace_from_input ? workspaceFromInputIfSlugMatches(sourcePath, input) : undefined);
|
|
110
115
|
|
|
111
116
|
// session id: filename uuid or extracted from first record
|
|
112
117
|
let providerSessionId: string | undefined;
|
|
@@ -248,6 +253,50 @@ function readSessionMetaWorkspace(lines: any[]): string | undefined {
|
|
|
248
253
|
return undefined;
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Return `input.workspace` when the resolved transcript file provably lives
|
|
258
|
+
* under that workspace's project-slug directory, else undefined.
|
|
259
|
+
*
|
|
260
|
+
* cursor-agent stores transcripts at `~/.cursor/projects/<slug>/…` where
|
|
261
|
+
* `<slug>` is the workspace realpath with every non-`[A-Za-z0-9_-]` char turned
|
|
262
|
+
* into `-` (the same transform claude uses, minus the leading dash from the root
|
|
263
|
+
* `/`). Long slugs are truncated and suffixed with a short hash
|
|
264
|
+
* (`<prefix>-<7hex>`). The transform is lossy, so we cannot reconstruct the real
|
|
265
|
+
* path from the slug — but we CAN verify a candidate workspace matches it. We
|
|
266
|
+
* compute the workspace's slug (both the claude form and the leading-`/`-stripped
|
|
267
|
+
* cursor form) and accept when a path segment of the file equals it OR is a
|
|
268
|
+
* truncated `<prefix>-<hash>` of it. On match the caller stamps the KNOWN real
|
|
269
|
+
* `input.workspace`, so downstream workspace comparison (path.resolve-based)
|
|
270
|
+
* still works; on mismatch we return undefined and the read fails closed rather
|
|
271
|
+
* than aliasing another workspace's transcript.
|
|
272
|
+
*/
|
|
273
|
+
function workspaceFromInputIfSlugMatches(sourcePath: string, input: NativeHistoryInput): string | undefined {
|
|
274
|
+
const wsRaw = typeof input.workspace === 'string' ? input.workspace.trim() : '';
|
|
275
|
+
if (!wsRaw) return undefined;
|
|
276
|
+
let wsReal = wsRaw;
|
|
277
|
+
try { wsReal = fs.realpathSync(wsRaw); } catch { /* keep raw */ }
|
|
278
|
+
const slugs = new Set<string>();
|
|
279
|
+
for (const w of [wsReal, wsRaw]) {
|
|
280
|
+
if (!w) continue;
|
|
281
|
+
slugs.add(claudeProjectDirName(w)); // "-Users-…" (leading dash)
|
|
282
|
+
slugs.add(claudeProjectDirName(w.replace(/^\/+/, ''))); // cursor form, no leading dash
|
|
283
|
+
}
|
|
284
|
+
const segments = sourcePath.split(path.sep);
|
|
285
|
+
for (const seg of segments) {
|
|
286
|
+
if (!seg) continue;
|
|
287
|
+
for (const slug of slugs) {
|
|
288
|
+
if (!slug) continue;
|
|
289
|
+
if (seg === slug) return wsRaw;
|
|
290
|
+
// Truncated+hashed cursor slug: `<prefix>-<7+hex>` where prefix is a
|
|
291
|
+
// leading portion of the full slug. Require a non-trivial prefix so a
|
|
292
|
+
// short common head can't false-match an unrelated workspace.
|
|
293
|
+
const m = seg.match(/^(.*)-[0-9a-f]{6,}$/);
|
|
294
|
+
if (m && m[1] && m[1].length >= 8 && slug.startsWith(m[1])) return wsRaw;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
251
300
|
function readJsonlLines(p: string): any[] {
|
|
252
301
|
let text: string;
|
|
253
302
|
try { text = fs.readFileSync(p, 'utf8'); } catch { return []; }
|
|
@@ -95,6 +95,23 @@ export interface NativeHistoryJsonlSource {
|
|
|
95
95
|
session_id_from?: 'filename_uuid' | 'first_record';
|
|
96
96
|
session_id_path?: string;
|
|
97
97
|
message_filter?: { where: string };
|
|
98
|
+
/**
|
|
99
|
+
* Fallback workspace attribution when the transcript carries no
|
|
100
|
+
* `session_meta` cwd record. Some stores (cursor-agent) do NOT write a
|
|
101
|
+
* session_meta line and keep the workspace only in the on-disk project-slug
|
|
102
|
+
* directory (`~/.cursor/projects/<slug>/…`), which is a lossy, sometimes
|
|
103
|
+
* truncated+hashed transform of the real path and cannot be reversed. When
|
|
104
|
+
* this flag is set and no session_meta workspace was found, the executor
|
|
105
|
+
* stamps `input.workspace` onto each message — but ONLY after confirming the
|
|
106
|
+
* resolved file actually lives under that workspace's project slug (a path
|
|
107
|
+
* segment matches the input workspace's cursor/claude slug, allowing for
|
|
108
|
+
* cursor's length-truncation). This closes the first-turn read gap (before a
|
|
109
|
+
* provider session id is pinned, the downstream hasSafeNativeHistoryMapping
|
|
110
|
+
* guard needs a per-message workspace) without risking cross-workspace
|
|
111
|
+
* aliasing: a slug mismatch leaves the workspace unset and the read fails
|
|
112
|
+
* closed as before.
|
|
113
|
+
*/
|
|
114
|
+
workspace_from_input?: boolean;
|
|
98
115
|
message_map: NativeHistoryMessageMap;
|
|
99
116
|
}
|
|
100
117
|
|