@adhdev/daemon-core 0.9.82-rc.540 → 0.9.82-rc.541

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.
@@ -71,8 +71,62 @@ export interface NativeHistoryJsonlSource {
71
71
  path: string;
72
72
  file_pattern?: string;
73
73
  recent_window_ms?: number;
74
- session_id_from?: 'filename_uuid' | 'first_record';
74
+ /**
75
+ * Where to read the provider session id from.
76
+ * 'filename_uuid' (default) — the transcript FILE basename embeds the
77
+ * uuid (`<uuid>.jsonl`, cursor-agent).
78
+ * 'first_record' — jsonpath (`session_id_path`) into the first record.
79
+ * 'dir_uuid' — a PARENT DIRECTORY segment embeds the uuid, and the
80
+ * leaf file has a fixed name. kimi persists every session at
81
+ * `~/.kimi-code/sessions/<wdKey>/session_<uuid>/agents/main/wire.jsonl`
82
+ * — the file is always `wire.jsonl`, so the uuid can only come from the
83
+ * `session_<uuid>` directory segment. The nearest ancestor segment
84
+ * containing a uuid wins.
85
+ */
86
+ session_id_from?: 'filename_uuid' | 'first_record' | 'dir_uuid';
75
87
  session_id_path?: string;
88
+ /**
89
+ * Fallback workspace attribution from a per-session SIDECAR json file when
90
+ * the transcript itself carries no `session_meta` cwd record AND the on-disk
91
+ * directory slug is irreversible. kimi's `wire.jsonl` has no cwd line and the
92
+ * `wd_<slug>_<sha12>` directory segment is a lossy slug + hash that cannot be
93
+ * reversed to the real workspace. But kimi writes a sibling `state.json` next
94
+ * to the session dir carrying the authoritative `workDir`. This option names
95
+ * that sidecar (relative to the resolved wire file's directory) and the json
96
+ * path to the workspace inside it; the executor reads it, stamps the value as
97
+ * the transcript workspace on every message, and — for workspace-scoped file
98
+ * selection (no pinned session id yet, the antigravity/opencode first-read
99
+ * case) — only accepts a candidate wire file whose sidecar workDir matches the
100
+ * input workspace. Mirrors cursor's `workspace_from_input` and opencode's
101
+ * per-row `message_map.workspace`, for a jsonl store whose workspace lives in
102
+ * a sidecar rather than in the transcript or a reversible slug.
103
+ */
104
+ workspace_from_sidecar?: {
105
+ /** Path to the sidecar json, relative to the resolved wire file's
106
+ * directory. e.g. `../../state.json` for
107
+ * `session_<uuid>/agents/main/wire.jsonl` → `session_<uuid>/state.json`. */
108
+ rel_path: string;
109
+ /** jsonpath-lite to the workspace string inside the sidecar. e.g.
110
+ * `$.workDir`. */
111
+ workspace_path: string;
112
+ };
113
+ /**
114
+ * Multi-shape record projection. A jsonl store whose user turns and
115
+ * assistant turns are DIFFERENT record types (so a single `message_map`
116
+ * can't extract both roles) declares one matcher per shape here. For each
117
+ * on-disk record the executor picks the FIRST entry whose `where` matches
118
+ * and projects the record with that entry's `message_map`; a record that
119
+ * matches no entry is dropped. kimi stores user turns as
120
+ * `type=="turn.prompt"` (`$.input[*].text`, role=user) and assistant text as
121
+ * `type=="context.append_loop_event"` content.part events
122
+ * (`$.event.part.text`, role=assistant) — two shapes with different role and
123
+ * content paths. Absent → the top-level single `message_map` (+ optional
124
+ * `message_filter`) path is used unchanged.
125
+ */
126
+ records?: Array<{
127
+ where?: string;
128
+ message_map: NativeHistoryMessageMap;
129
+ }>;
76
130
  message_filter?: {
77
131
  where: string;
78
132
  };
@@ -93,7 +147,8 @@ export interface NativeHistoryJsonlSource {
93
147
  * closed as before.
94
148
  */
95
149
  workspace_from_input?: boolean;
96
- message_map: NativeHistoryMessageMap;
150
+ /** Single-shape projection. Required unless `records` (multi-shape) is set. */
151
+ message_map?: NativeHistoryMessageMap;
97
152
  }
98
153
  export interface NativeHistorySqliteSource {
99
154
  kind: 'sqlite';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.540",
3
+ "version": "0.9.82-rc.541",
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.540",
51
- "@adhdev/session-host-core": "0.9.82-rc.540",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.541",
51
+ "@adhdev/session-host-core": "0.9.82-rc.541",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -112,6 +112,21 @@ const IDLE_CONFIRMATION_GRACE_MS = 2_000;
112
112
  // so a genuinely-finished turn can never be held past this bound (no infinite defer).
113
113
  const APPROVAL_RESUME_IDLE_DEFER_CAP_MS = 18_000;
114
114
 
115
+ // FALSE-IDLE (screen-quiet gate): the generating→idle transition (both the
116
+ // candidate-confirmed finish AND the idleFinish timeout finish) requires the
117
+ // VISIBLE TERMINAL SCREEN CONTENT to have been byte-identical for at least this
118
+ // long, continuously. `snap.lastScreenChangeAt` is bumped by the adapter every
119
+ // time the normalized screen snapshot changes (a spinner frame, streaming
120
+ // command output, etc.), so `now - lastScreenChangeAt` is the real screen-diff
121
+ // quiet age. A worker that is WAITING on a long-running foreground command
122
+ // (e.g. a `curl :3847/health` poll loop) keeps repainting the screen, so its
123
+ // quiet age never reaches this threshold and it can never be declared idle.
124
+ // This is a NECESSARY gate layered on top of the existing settle conditions —
125
+ // it only ever prevents a finish, never forces one. Owner-chosen at 5s (8s felt
126
+ // too long). Screen snapshots are read at most every 250ms
127
+ // (SCREEN_SNAPSHOT_MIN_INTERVAL_MS), so the granularity is well under 5s.
128
+ const SCREEN_QUIET_IDLE_MS = 5_000;
129
+
115
130
  // ─── Engine ────────────────────────────────────────────────────────────────
116
131
 
117
132
  export class CliStateEngine {
@@ -773,6 +788,13 @@ export class CliStateEngine {
773
788
  this.idleTimeout = setTimeout(() => {
774
789
  if (this.isWaitingForResponse && !this.hasActionableApproval()) {
775
790
  if (this.shouldDeferIdleTimeoutFinish()) return;
791
+ // FALSE-IDLE (screen-quiet gate): a recent_activity_hold worker still
792
+ // repainting (spinner + streaming command output) must NOT be finished
793
+ // by this timeout. Re-arm and re-evaluate until the screen is quiet.
794
+ if (!this.hasScreenBeenQuietForIdle(Date.now())) {
795
+ this.evaluateSettled(this.transport.getSnapshot());
796
+ return;
797
+ }
776
798
  this.finishResponse();
777
799
  }
778
800
  }, this.timeouts.generatingIdle);
@@ -794,6 +816,12 @@ export class CliStateEngine {
794
816
  this.idleTimeout = setTimeout(() => {
795
817
  if (this.isWaitingForResponse && !this.hasActionableApproval()) {
796
818
  if (this.shouldDeferIdleTimeoutFinish()) return;
819
+ // FALSE-IDLE (screen-quiet gate): do not finish while the screen
820
+ // is still repainting (see applyHoldGenerating).
821
+ if (!this.hasScreenBeenQuietForIdle(Date.now())) {
822
+ this.evaluateSettled(this.transport.getSnapshot());
823
+ return;
824
+ }
797
825
  this.finishResponse();
798
826
  }
799
827
  }, this.timeouts.generatingIdle);
@@ -922,6 +950,15 @@ export class CliStateEngine {
922
950
  this.idleTimeout = setTimeout(() => {
923
951
  if (this.isWaitingForResponse) {
924
952
  if (this.shouldDeferIdleTimeoutFinish()) return;
953
+ // FALSE-IDLE (screen-quiet gate): the generating→idle timeout must NOT
954
+ // finish while the visible screen is still changing (spinner + streaming
955
+ // command output). This is the primary false-idle path — a worker WAITING
956
+ // on a long-running foreground command sits in generating with a live,
957
+ // repainting screen. Re-evaluate until the screen has been quiet >= 5s.
958
+ if (!this.hasScreenBeenQuietForIdle(Date.now())) {
959
+ this.evaluateSettled(this.transport.getSnapshot());
960
+ return;
961
+ }
925
962
  this.finishResponse();
926
963
  }
927
964
  }, this.timeouts.generatingIdle);
@@ -1007,7 +1044,15 @@ export class CliStateEngine {
1007
1044
  const assistantLength = (lastParsedAssistant as any)?.content?.length || 0;
1008
1045
  const idleFinishConfirmMs = this.timeouts.idleFinishConfirm;
1009
1046
  const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
1010
- const idleReady = !modal && hasAssistantTurn && quietForMs >= idleQuietThresholdMs && screenStableMs >= idleFinishConfirmMs;
1047
+ // FALSE-IDLE (screen-quiet gate): NECESSARY condition the visible screen
1048
+ // content must have been byte-identical for >= SCREEN_QUIET_IDLE_MS. A worker
1049
+ // still repainting (spinner frame, streaming command output) resets
1050
+ // lastScreenChangeAt on every change, so its quiet age never reaches the
1051
+ // threshold and it can never arm/confirm idle. Layered on top of the existing
1052
+ // conditions — it only ever prevents a finish, never forces one.
1053
+ const screenQuietForIdle = screenStableMs >= SCREEN_QUIET_IDLE_MS;
1054
+ const idleReady = !modal && hasAssistantTurn && quietForMs >= idleQuietThresholdMs
1055
+ && screenStableMs >= idleFinishConfirmMs && screenQuietForIdle;
1011
1056
  const candidate = this.idleFinishCandidate;
1012
1057
  const candidateQuiet = !!candidate && candidate.responseEpoch === this.responseEpoch
1013
1058
  && candidate.lastOutputAt === snap.lastOutputAt
@@ -1060,6 +1105,19 @@ export class CliStateEngine {
1060
1105
  return;
1061
1106
  }
1062
1107
  if (this.shouldDeferIdleTimeoutFinish()) return;
1108
+ // FALSE-IDLE (screen-quiet gate): the idleFinish timeout must NOT
1109
+ // finish while the visible screen is still changing. A worker
1110
+ // WAITING on a long-running foreground command keeps repainting the
1111
+ // screen (spinner + streaming output), so lastScreenChangeAt stays
1112
+ // fresh and the quiet age never reaches SCREEN_QUIET_IDLE_MS. Re-arm
1113
+ // and re-evaluate instead of emitting a weak/false completion.
1114
+ if (!this.hasScreenBeenQuietForIdle(Date.now())) {
1115
+ if (this.idleTimeout) clearTimeout(this.idleTimeout);
1116
+ this.idleTimeout = setTimeout(() => {
1117
+ if (this.isWaitingForResponse) this.evaluateSettled(this.transport.getSnapshot());
1118
+ }, this.timeouts.idleFinish);
1119
+ return;
1120
+ }
1063
1121
  const parsed = this.runParseSession(this.transport.getSnapshot());
1064
1122
  if (this.shouldDeferFinishForTranscript(parsed)) {
1065
1123
  this.rescheduleTranscriptFinishCheck('transcript_idle_timeout_not_final');
@@ -1071,6 +1129,23 @@ export class CliStateEngine {
1071
1129
  }, this.timeouts.idleFinish);
1072
1130
  }
1073
1131
 
1132
+ /**
1133
+ * FALSE-IDLE (screen-quiet gate): has the visible terminal screen content been
1134
+ * byte-identical for at least SCREEN_QUIET_IDLE_MS continuously?
1135
+ *
1136
+ * `lastScreenChangeAt` is bumped by the adapter every time the normalized screen
1137
+ * snapshot changes (spinner frame, streaming command output, etc.), so
1138
+ * `now - lastScreenChangeAt` is the real screen-diff quiet age. Reads the LIVE
1139
+ * transport snapshot so the deferred idleFinish timeout re-checks current screen
1140
+ * state, not the stale snapshot from when the timer was armed. A never-changed
1141
+ * screen (lastScreenChangeAt === 0) is treated as quiet.
1142
+ */
1143
+ private hasScreenBeenQuietForIdle(now: number): boolean {
1144
+ const lastChange = this.transport.getSnapshot().lastScreenChangeAt;
1145
+ if (!lastChange) return true;
1146
+ return (now - lastChange) >= SCREEN_QUIET_IDLE_MS;
1147
+ }
1148
+
1074
1149
  /**
1075
1150
  * FALSE-IDLE (Fix 2): should applyIdle suppress the idle/finish for the current
1076
1151
  * turn because we are inside the post-approval resume grace?
@@ -3,6 +3,7 @@
3
3
  * native-history / source-resolution / normalization helpers they use.
4
4
  */
5
5
 
6
+ import * as fs from 'node:fs';
6
7
  import * as path from 'node:path';
7
8
  import type { CommandResult, CommandHelpers } from './handler.js';
8
9
  import type { CliAdapter } from '../cli-adapter-types.js';
@@ -1151,7 +1152,33 @@ function readExactRuntimeMirrorMessages(args: {
1151
1152
  function normalizeComparableWorkspace(value: unknown): string {
1152
1153
  const text = typeof value === 'string' ? value.trim() : '';
1153
1154
  if (!text) return '';
1154
- return path.resolve(text);
1155
+ // Canonicalize via realpath so symlink aliases compare equal. On macOS
1156
+ // `/tmp` is a symlink to `/private/tmp`: a provider whose on-disk workspace
1157
+ // record is stored realpath'd (kimi's state.json workDir → `/private/tmp/…`)
1158
+ // must still match an ADHDev session workspace passed as `/tmp/…`. Without
1159
+ // this the native-history workspace-safety gate (workspace_from_sidecar) saw
1160
+ // a false mismatch, marked the read unsafe, and fell back to the PTY parser.
1161
+ // realpath throws when the path doesn't exist (e.g. a stale/never-created
1162
+ // workspace) — fall back to the lexical resolve then, never crash the read
1163
+ // path. Fail-closed cross-workspace safety is preserved: two genuinely
1164
+ // different directories still realpath to different paths, and the lexical
1165
+ // fallback is unchanged from the prior behaviour.
1166
+ const lexical = path.resolve(text);
1167
+ try {
1168
+ return fs.realpathSync.native(lexical);
1169
+ } catch {
1170
+ try {
1171
+ return fs.realpathSync(lexical);
1172
+ } catch {
1173
+ return lexical;
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ /** Test hook for the symlink-safe workspace comparison used by the
1179
+ * native-history workspace-safety gate. */
1180
+ export function __normalizeComparableWorkspaceForTest(value: unknown): string {
1181
+ return normalizeComparableWorkspace(value);
1155
1182
  }
1156
1183
  function isCurrentRuntimePtySafelyAttributed(args: {
1157
1184
  adapter: CliAdapter;
@@ -842,7 +842,8 @@ function buildRulesSection(coordinatorCliType?: string): string {
842
842
 
843
843
  return `## Rules
844
844
 
845
- - **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean.
845
+ - **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean. See also: **Never use local sub-agents** below.
846
+ - **Never use local sub-agents.** Do NOT spawn your runtime's own sub-agents (e.g. Claude Code's Task/Explore/Agent tools, or any equivalent in-process agent-spawning tool) to read code, investigate, run RCA, or implement. Such sub-agents execute on the coordinator's machine, outside the mesh — they escape mesh parallelism, the ledger/audit trail, node capability profiles, and worktree isolation, and leave no \`mesh_task_history\` record. ALL code reading, analysis, RCA, and implementation must be delegated to mesh nodes via \`mesh_enqueue_task\` / \`mesh_send_task\` (use \`task_mode: "live_debug_readonly"\` for read-only investigation), or cross-verified via \`mesh_magi_review\` for read-only fan-out. The coordinator's own actions are limited to \`mesh_*\` tool orchestration and synthesizing results.
846
847
  - **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
847
848
  - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start a fresh session only when: (a) branch/worktree isolation is required, (b) the existing session had a dispatch failure or provider mismatch, (c) the transcript/runtime is contaminated or interrupted, or (d) the user explicitly asks for a different provider/session. Continuation of the same issue in an already-idle session is allowed and preferred — this rule blocks concurrent unrelated work interleaved into a live (still-generating) session, not sequential same-issue follow-ups.
848
849
  - **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
@@ -109,29 +109,40 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
109
109
  // Prefer an in-transcript session_meta cwd; fall back to the input workspace
110
110
  // only when the spec opts in AND the resolved file lives under that
111
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).
112
+ // workspace in the lossy on-disk slug — see workspace_from_input). A store
113
+ // whose workspace lives in a per-session sidecar json (kimi's state.json,
114
+ // whose `wd_<slug>_<sha12>` dir is irreversible) reads it from there.
113
115
  const transcriptWorkspace = readSessionMetaWorkspace(lines)
116
+ ?? (src.workspace_from_sidecar ? readSidecarWorkspace(sourcePath, src.workspace_from_sidecar) : undefined)
114
117
  ?? (src.workspace_from_input ? workspaceFromInputIfSlugMatches(sourcePath, input) : undefined);
115
118
 
116
- // session id: filename uuid or extracted from first record
119
+ // session id: filename uuid, a parent directory uuid, or extracted from the
120
+ // first record.
117
121
  let providerSessionId: string | undefined;
118
122
  if (src.session_id_from === 'first_record' && src.session_id_path) {
119
123
  const v = jsonPathGet(lines[0], src.session_id_path);
120
124
  if (typeof v === 'string' && v) providerSessionId = v;
125
+ } else if (src.session_id_from === 'dir_uuid') {
126
+ providerSessionId = dirUuid(sourcePath) || undefined;
121
127
  } else if (src.session_id_from === 'filename_uuid' || !src.session_id_from) {
122
128
  const m = path.basename(sourcePath).match(UUID_RE);
123
129
  if (m) providerSessionId = m[1];
124
130
  }
125
131
 
132
+ // Compare requested vs resolved by embedded uuid so a `session_<uuid>` pin
133
+ // (kimi's on-disk session id carries a `session_` prefix) still matches the
134
+ // bare uuid the executor extracts from the directory segment.
126
135
  const requested = readRequestedSessionId(input) || '';
127
- if (requested && providerSessionId && providerSessionId !== requested) return null;
136
+ if (requested && providerSessionId && !sameSessionUuid(providerSessionId, requested)) return null;
128
137
 
129
- const filter = src.message_filter ? compileWhere(src.message_filter.where) : null;
138
+ // Multi-shape (records[]) vs single-shape (message_map) projection.
139
+ const shapes = compileRecordShapes(src);
130
140
  const messages: NativeHistoryMessage[] = [];
131
141
  for (let i = 0; i < lines.length; i += 1) {
132
142
  const rec = lines[i];
133
- if (filter && !filter(rec)) continue;
134
- for (const msg of projectMessages(rec, src.message_map, i, lines.length, mtime)) {
143
+ const shape = shapes.pick(rec);
144
+ if (!shape) continue;
145
+ for (const msg of projectMessages(rec, shape.map, i, lines.length, mtime)) {
135
146
  if (transcriptWorkspace) msg.workspace = transcriptWorkspace;
136
147
  messages.push(msg);
137
148
  }
@@ -188,9 +199,31 @@ export function resolveJsonlSourcePath(src: NativeHistoryJsonlSource, input: Nat
188
199
  const workspaceHint = typeof input.workspace === 'string' && input.workspace.trim() ? input.workspace.trim() : '';
189
200
  let sourcePath: string | null = null;
190
201
  if (resolved.includes('*')) {
191
- sourcePath = pickExactSessionFileAcrossGlob(resolved, filePat, requestedSessionId)
192
- || pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
193
- || newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
202
+ // dir_uuid + sidecar-workspace stores (kimi): the session id lives in a
203
+ // parent directory segment (not the fixed leaf filename) and the
204
+ // workspace lives in a per-session sidecar json (not the irreversible
205
+ // `wd_<slug>_<sha12>` dir, not the transcript). The filename-uuid pickers
206
+ // can't match here, so select by the directory uuid when pinned, else by
207
+ // the sidecar workDir + recency when workspace-scoped.
208
+ if (src.session_id_from === 'dir_uuid' || src.workspace_from_sidecar) {
209
+ // Pinned → match by directory uuid. Unpinned + a workspace hint →
210
+ // scope to the sidecar workDir and FAIL CLOSED (no workspace-blind
211
+ // newest-file fallback) so another workspace's session is never
212
+ // aliased. Only when there is neither a pin nor a workspace hint do
213
+ // we fall back to newest-recent (single-session dev/test case).
214
+ sourcePath = pickDirUuidFileAcrossGlob(resolved, filePat, requestedSessionId);
215
+ if (!sourcePath && !requestedSessionId) {
216
+ if (src.workspace_from_sidecar && workspaceHint) {
217
+ sourcePath = pickSidecarWorkspaceFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint, src.workspace_from_sidecar);
218
+ } else {
219
+ sourcePath = newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
220
+ }
221
+ }
222
+ } else {
223
+ sourcePath = pickExactSessionFileAcrossGlob(resolved, filePat, requestedSessionId)
224
+ || pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
225
+ || newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
226
+ }
194
227
  } else {
195
228
  let stat: fs.Stats | null = null;
196
229
  try { stat = fs.statSync(resolved); } catch { /* fall through to date-walk fallback */ }
@@ -253,6 +286,82 @@ function readSessionMetaWorkspace(lines: any[]): string | undefined {
253
286
  return undefined;
254
287
  }
255
288
 
289
+ /**
290
+ * Read the workspace from a per-session sidecar json file (kimi's state.json).
291
+ * `rel_path` is resolved relative to the wire file's directory and the workspace
292
+ * is pulled out via `workspace_path` (jsonpath-lite). Returns undefined on any
293
+ * miss so the caller falls through to the next attribution strategy.
294
+ */
295
+ function readSidecarWorkspace(
296
+ sourcePath: string,
297
+ cfg: { rel_path: string; workspace_path: string },
298
+ ): string | undefined {
299
+ try {
300
+ const sidecar = path.resolve(path.dirname(sourcePath), cfg.rel_path);
301
+ const parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8'));
302
+ const v = jsonPathGet(parsed, cfg.workspace_path);
303
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Extract a uuid from the nearest ancestor DIRECTORY segment of a file path
311
+ * (kimi's `.../session_<uuid>/agents/main/wire.jsonl`). Walks from the leaf's
312
+ * parent upward and returns the first uuid found, or '' when none.
313
+ */
314
+ function dirUuid(filePath: string): string {
315
+ const segs = path.dirname(filePath).split(path.sep);
316
+ for (let i = segs.length - 1; i >= 0; i -= 1) {
317
+ const m = segs[i].match(UUID_RE);
318
+ if (m) return m[1];
319
+ }
320
+ return '';
321
+ }
322
+
323
+ /** Compare two session ids by their embedded uuid, ignoring any prefix/suffix
324
+ * (kimi's `session_<uuid>` pin vs the bare `<uuid>` the executor extracts). */
325
+ function sameSessionUuid(a: string, b: string): boolean {
326
+ if (a === b) return true;
327
+ const ua = a.match(UUID_RE)?.[1]?.toLowerCase();
328
+ const ub = b.match(UUID_RE)?.[1]?.toLowerCase();
329
+ return !!ua && !!ub && ua === ub;
330
+ }
331
+
332
+ /**
333
+ * Resolve the projection strategy for a jsonl source. Multi-shape (`records[]`)
334
+ * picks the first entry whose `where` matches a record; single-shape falls back
335
+ * to the top-level `message_map` gated by the optional `message_filter`.
336
+ */
337
+ function compileRecordShapes(src: NativeHistoryJsonlSource): {
338
+ pick: (record: any) => { map: NativeHistoryMessageMap } | null;
339
+ } {
340
+ if (Array.isArray(src.records) && src.records.length > 0) {
341
+ const compiled = src.records.map((r) => ({
342
+ where: r.where ? compileWhere(r.where) : null,
343
+ map: r.message_map,
344
+ }));
345
+ return {
346
+ pick: (record: any) => {
347
+ for (const shape of compiled) {
348
+ if (!shape.where || shape.where(record)) return { map: shape.map };
349
+ }
350
+ return null;
351
+ },
352
+ };
353
+ }
354
+ const filter = src.message_filter ? compileWhere(src.message_filter.where) : null;
355
+ const map = src.message_map;
356
+ return {
357
+ pick: (record: any) => {
358
+ if (!map) return null;
359
+ if (filter && !filter(record)) return null;
360
+ return { map };
361
+ },
362
+ };
363
+ }
364
+
256
365
  /**
257
366
  * Return `input.workspace` when the resolved transcript file provably lives
258
367
  * under that workspace's project-slug directory, else undefined.
@@ -835,6 +944,62 @@ function pickExactSessionFileAcrossGlob(template: string, pattern: RegExp, reque
835
944
  return matches[0] || null;
836
945
  }
837
946
 
947
+ /**
948
+ * dir_uuid exact pick across a glob: the requested session uuid is embedded in a
949
+ * parent DIRECTORY segment (kimi's `session_<uuid>/…/wire.jsonl`), not the leaf
950
+ * filename. Match the file whose ancestor path carries the requested uuid.
951
+ */
952
+ function pickDirUuidFileAcrossGlob(template: string, pattern: RegExp, requestedSessionId: string): string | null {
953
+ if (!requestedSessionId) return null;
954
+ const wantUuid = requestedSessionId.match(UUID_RE)?.[1]?.toLowerCase();
955
+ if (!wantUuid) return null;
956
+ const dirs = expandDirGlob(template);
957
+ const matches: string[] = [];
958
+ for (const d of dirs) {
959
+ for (const p of listMatchingFiles(d, pattern)) {
960
+ if (dirUuid(p).toLowerCase() === wantUuid) matches.push(p);
961
+ }
962
+ }
963
+ matches.sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a));
964
+ return matches[0] || null;
965
+ }
966
+
967
+ /**
968
+ * Workspace-scoped pick across a glob for a sidecar-workspace store (kimi's
969
+ * first read, before a session id is pinned): among candidate wire files within
970
+ * the recency window, keep only those whose sidecar `state.json` workDir matches
971
+ * the input workspace, then take the newest. Fails closed (null) when no sidecar
972
+ * matches so an unrelated workspace's session is never aliased.
973
+ */
974
+ function pickSidecarWorkspaceFileAcrossGlob(
975
+ template: string,
976
+ pattern: RegExp,
977
+ windowMs: number,
978
+ sessionFloorMs: number,
979
+ workspaceHint: string,
980
+ sidecar?: { rel_path: string; workspace_path: string },
981
+ ): string | null {
982
+ if (!sidecar || !workspaceHint) return null;
983
+ let wsResolved = workspaceHint;
984
+ try { wsResolved = fs.realpathSync(workspaceHint); } catch { /* keep raw */ }
985
+ const dirs = expandDirGlob(template);
986
+ const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs);
987
+ let best: { p: string; mtime: number } | null = null;
988
+ for (const d of dirs) {
989
+ for (const p of listMatchingFiles(d, pattern)) {
990
+ const mtime = safeMtimeMs(p);
991
+ if (mtime < cutoff) continue;
992
+ const ws = readSidecarWorkspace(p, sidecar);
993
+ if (!ws) continue;
994
+ let wsReal = ws;
995
+ try { wsReal = fs.realpathSync(ws); } catch { /* keep raw */ }
996
+ if (ws !== workspaceHint && wsReal !== wsResolved) continue;
997
+ if (!best || mtime > best.mtime) best = { p, mtime };
998
+ }
999
+ }
1000
+ return best ? best.p : null;
1001
+ }
1002
+
838
1003
  function pickExactSessionFileAcrossDateWindow(
839
1004
  template: string,
840
1005
  input: NativeHistoryInput,
@@ -92,8 +92,59 @@ export interface NativeHistoryJsonlSource {
92
92
  path: string;
93
93
  file_pattern?: string;
94
94
  recent_window_ms?: number;
95
- session_id_from?: 'filename_uuid' | 'first_record';
95
+ /**
96
+ * Where to read the provider session id from.
97
+ * 'filename_uuid' (default) — the transcript FILE basename embeds the
98
+ * uuid (`<uuid>.jsonl`, cursor-agent).
99
+ * 'first_record' — jsonpath (`session_id_path`) into the first record.
100
+ * 'dir_uuid' — a PARENT DIRECTORY segment embeds the uuid, and the
101
+ * leaf file has a fixed name. kimi persists every session at
102
+ * `~/.kimi-code/sessions/<wdKey>/session_<uuid>/agents/main/wire.jsonl`
103
+ * — the file is always `wire.jsonl`, so the uuid can only come from the
104
+ * `session_<uuid>` directory segment. The nearest ancestor segment
105
+ * containing a uuid wins.
106
+ */
107
+ session_id_from?: 'filename_uuid' | 'first_record' | 'dir_uuid';
96
108
  session_id_path?: string;
109
+ /**
110
+ * Fallback workspace attribution from a per-session SIDECAR json file when
111
+ * the transcript itself carries no `session_meta` cwd record AND the on-disk
112
+ * directory slug is irreversible. kimi's `wire.jsonl` has no cwd line and the
113
+ * `wd_<slug>_<sha12>` directory segment is a lossy slug + hash that cannot be
114
+ * reversed to the real workspace. But kimi writes a sibling `state.json` next
115
+ * to the session dir carrying the authoritative `workDir`. This option names
116
+ * that sidecar (relative to the resolved wire file's directory) and the json
117
+ * path to the workspace inside it; the executor reads it, stamps the value as
118
+ * the transcript workspace on every message, and — for workspace-scoped file
119
+ * selection (no pinned session id yet, the antigravity/opencode first-read
120
+ * case) — only accepts a candidate wire file whose sidecar workDir matches the
121
+ * input workspace. Mirrors cursor's `workspace_from_input` and opencode's
122
+ * per-row `message_map.workspace`, for a jsonl store whose workspace lives in
123
+ * a sidecar rather than in the transcript or a reversible slug.
124
+ */
125
+ workspace_from_sidecar?: {
126
+ /** Path to the sidecar json, relative to the resolved wire file's
127
+ * directory. e.g. `../../state.json` for
128
+ * `session_<uuid>/agents/main/wire.jsonl` → `session_<uuid>/state.json`. */
129
+ rel_path: string;
130
+ /** jsonpath-lite to the workspace string inside the sidecar. e.g.
131
+ * `$.workDir`. */
132
+ workspace_path: string;
133
+ };
134
+ /**
135
+ * Multi-shape record projection. A jsonl store whose user turns and
136
+ * assistant turns are DIFFERENT record types (so a single `message_map`
137
+ * can't extract both roles) declares one matcher per shape here. For each
138
+ * on-disk record the executor picks the FIRST entry whose `where` matches
139
+ * and projects the record with that entry's `message_map`; a record that
140
+ * matches no entry is dropped. kimi stores user turns as
141
+ * `type=="turn.prompt"` (`$.input[*].text`, role=user) and assistant text as
142
+ * `type=="context.append_loop_event"` content.part events
143
+ * (`$.event.part.text`, role=assistant) — two shapes with different role and
144
+ * content paths. Absent → the top-level single `message_map` (+ optional
145
+ * `message_filter`) path is used unchanged.
146
+ */
147
+ records?: Array<{ where?: string; message_map: NativeHistoryMessageMap }>;
97
148
  message_filter?: { where: string };
98
149
  /**
99
150
  * Fallback workspace attribution when the transcript carries no
@@ -112,7 +163,8 @@ export interface NativeHistoryJsonlSource {
112
163
  * closed as before.
113
164
  */
114
165
  workspace_from_input?: boolean;
115
- message_map: NativeHistoryMessageMap;
166
+ /** Single-shape projection. Required unless `records` (multi-shape) is set. */
167
+ message_map?: NativeHistoryMessageMap;
116
168
  }
117
169
 
118
170
  export interface NativeHistorySqliteSource {