@dorokuma/herdsman 0.13.6 → 0.14.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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/dist/src/agent-history/discovery.d.ts +47 -11
  3. package/dist/src/agent-history/discovery.d.ts.map +1 -1
  4. package/dist/src/agent-history/discovery.js +242 -468
  5. package/dist/src/agent-history/discovery.js.map +1 -1
  6. package/dist/src/agent-history/service.d.ts +4 -7
  7. package/dist/src/agent-history/service.d.ts.map +1 -1
  8. package/dist/src/agent-history/service.js +46 -30
  9. package/dist/src/agent-history/service.js.map +1 -1
  10. package/dist/src/daemon/agent-event-reconciler.js +6 -10
  11. package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
  12. package/dist/src/daemon/herdr-session-watch-manager.js +2 -2
  13. package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
  14. package/dist/src/daemon/observability-server.js +6 -6
  15. package/dist/src/daemon/observability-server.js.map +1 -1
  16. package/dist/src/db/agents.d.ts +0 -2
  17. package/dist/src/db/agents.d.ts.map +1 -1
  18. package/dist/src/db/agents.js +14 -57
  19. package/dist/src/db/agents.js.map +1 -1
  20. package/dist/src/herdr/pane-identity-resolver.d.ts +11 -0
  21. package/dist/src/herdr/pane-identity-resolver.d.ts.map +1 -1
  22. package/dist/src/herdr/pane-identity-resolver.js +27 -15
  23. package/dist/src/herdr/pane-identity-resolver.js.map +1 -1
  24. package/dist/src/herdr/session-list.d.ts.map +1 -1
  25. package/dist/src/herdr/session-list.js +4 -2
  26. package/dist/src/herdr/session-list.js.map +1 -1
  27. package/dist/src/observability/agent-context-service.d.ts +4 -10
  28. package/dist/src/observability/agent-context-service.d.ts.map +1 -1
  29. package/dist/src/observability/agent-context-service.js +37 -58
  30. package/dist/src/observability/agent-context-service.js.map +1 -1
  31. package/dist/src/observability/agent-index-service.d.ts.map +1 -1
  32. package/dist/src/observability/agent-index-service.js +15 -22
  33. package/dist/src/observability/agent-index-service.js.map +1 -1
  34. package/dist/src/observability/contracts.d.ts +1 -2
  35. package/dist/src/observability/contracts.d.ts.map +1 -1
  36. package/dist/src/observability/contracts.js.map +1 -1
  37. package/package.json +1 -1
  38. package/dist/src/agent-history/claude-reader.d.ts +0 -10
  39. package/dist/src/agent-history/claude-reader.d.ts.map +0 -1
  40. package/dist/src/agent-history/claude-reader.js +0 -90
  41. package/dist/src/agent-history/claude-reader.js.map +0 -1
  42. package/dist/src/agent-history/codex-reader.d.ts +0 -10
  43. package/dist/src/agent-history/codex-reader.d.ts.map +0 -1
  44. package/dist/src/agent-history/codex-reader.js +0 -119
  45. package/dist/src/agent-history/codex-reader.js.map +0 -1
  46. package/dist/src/agent-history/gemini-reader.d.ts +0 -10
  47. package/dist/src/agent-history/gemini-reader.d.ts.map +0 -1
  48. package/dist/src/agent-history/gemini-reader.js +0 -96
  49. package/dist/src/agent-history/gemini-reader.js.map +0 -1
  50. package/dist/src/agent-history/grok-reader.d.ts +0 -11
  51. package/dist/src/agent-history/grok-reader.d.ts.map +0 -1
  52. package/dist/src/agent-history/grok-reader.js +0 -54
  53. package/dist/src/agent-history/grok-reader.js.map +0 -1
  54. package/dist/src/agent-history/opencode-reader.d.ts +0 -10
  55. package/dist/src/agent-history/opencode-reader.d.ts.map +0 -1
  56. package/dist/src/agent-history/opencode-reader.js +0 -90
  57. package/dist/src/agent-history/opencode-reader.js.map +0 -1
@@ -1,203 +1,276 @@
1
- import { createReadStream, existsSync, lstatSync, realpathSync } from "node:fs";
2
- import { lstat, readdir, readFile, stat } from "node:fs/promises";
1
+ import { lstatSync, realpathSync } from "node:fs";
3
2
  import { isAbsolute, join, normalize, relative } from "node:path";
4
- import { createInterface } from "node:readline";
5
- import { DatabaseSync } from "node:sqlite";
6
- // A pane agent's authoritative session ref (herdr detection or herdsman-pi
7
- // registration) may not have landed when the agent is first observed, so
8
- // discovery falls back to cwd-based guessing. A session file untouched since
9
- // before the agent was first seen cannot be that agent's live session.
10
- // Herdr observation delay is on the second scale. Pi and role-hinted lookups
11
- // use a 30-second grace window to absorb detection lag; other executor
12
- // fallbacks (claude/codex/gemini/grok) keep 10 minutes so idle sessions are
13
- // not dropped. Antigravity already short-circuits before recency ranking.
14
- // Dispatched Pi role sessions live under /tmp/herdr-role-sessions/<herdr-session>/.
15
- // Exact-path registration may resolve any file under this root; fallback/id scans
16
- // must be scoped to the agent's own herdr session subdirectory, and when the pane
17
- // terminal title contains a complete role-<id> segment, to that role directory only
18
- // (no cross-role adopt, and ~/.pi/agent/sessions is excluded while the hint is set).
3
+ /**
4
+ * Herdsman supports session history for exactly two agents, by decision:
5
+ *
6
+ * 1. `pi` — Herdr's own integration reports the session file itself, so the
7
+ * official `agent_session.kind == "path"` value is used as-is after the
8
+ * file-safety gate (this is the generic path channel, not a pi-only branch).
9
+ * 2. `agy` — Herdr reports only `kind == "id"`, and an agy conversation id is
10
+ * the name of its own conversation database, so the id resolves to exactly
11
+ * one candidate:
12
+ * - `agy` -> `<home>/.gemini/antigravity-cli/conversations/<id>.db`
13
+ *
14
+ * Every other agent — whether or not Herdr has an integration for it — is
15
+ * deliberately not resolved: herdsman does not walk agent directories, rank
16
+ * candidates by mtime/cwd, match role directories from the terminal title, or
17
+ * pick "the newest plausible file". An unresolvable pane is an explicit failure:
18
+ * one `console.warn` naming the official values plus the derived candidate path,
19
+ * then `null`.
20
+ */
19
21
  export const ALLOWED_SESSION_ROOTS = ["/tmp/herdr-role-sessions"];
20
- export const DISCOVERY_RECENCY_GRACE_MS = 30_000;
21
- export const FALLBACK_DISCOVERY_RECENCY_GRACE_MS = 10 * 60_000;
22
- export async function discoverAgentHistory(input) {
23
- if (input.agentSession?.kind === "path") {
24
- const resolved = safeAllowedSessionPath(input.agentSession.value, input.homeDir);
25
- if (resolved && existsSync(resolved)) {
26
- const source = historySourceFromSessionRef(input.agentSession);
27
- return { kind: "agent_session", path: resolved, source, value: resolved };
28
- }
29
- }
30
- const cwd = input.cwd ?? input.foregroundCwd;
31
- const normalizedCwd = normalizeCwd(cwd);
32
- const homeDir = input.homeDir ?? process.env.HOME ?? "";
33
- if (input.agentSession?.kind === "id") {
34
- const source = historySourceFromSessionRef(input.agentSession);
35
- if (source === "grok-jsonl") {
36
- const matches = await discoverGrokSessions({
37
- homeDir,
38
- cwd,
39
- sessionId: input.agentSession.value,
40
- ...(input.grokHome === undefined ? {} : { grokHome: input.grokHome }),
41
- });
42
- const candidate = selectUniqueCandidate(matches, normalizedCwd, input.occupiedSessionPaths);
43
- if (candidate)
44
- return {
45
- kind: "agent_session",
46
- path: candidate.path,
47
- source,
48
- value: input.agentSession.value,
49
- };
50
- }
51
- if (source === "antigravity-sqlite") {
52
- const ref = discoverAntigravitySession(homeDir, input.agentSession.value);
53
- if (ref)
54
- return { ...ref, kind: "agent_session" };
55
- }
56
- if (source === "pi-jsonl") {
57
- const roots = new Set(piJsonlScanRoots(homeDir, input.herdrSessionName, roleDirectoryHint(input)));
58
- for (const root of roots) {
59
- const matches = await scanRootById(root, input.agentSession.value, source);
60
- const candidate = matches.find((item) => !input.occupiedSessionPaths?.has(item.path));
61
- if (candidate) {
62
- return {
63
- kind: "agent_session",
64
- path: candidate.path,
65
- source,
66
- value: input.agentSession.value,
67
- };
68
- }
69
- }
70
- }
71
- if (source === "opencode-sqlite") {
72
- const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: input.agentSession.value });
73
- if (ref)
74
- return { ...ref, kind: "agent_session" };
22
+ /**
23
+ * A pane whose official value cannot be resolved must not be re-resolved — nor
24
+ * re-warned — on every refresh round. The failure is remembered per official
25
+ * input (agent, kind, source, value, store root) for
26
+ * `DISCOVERY_RETRY_AFTER_FAILURE_MS`: within that window the lookup is skipped
27
+ * entirely, and only the first failure for a key logs a warning. Any change in
28
+ * the official values (or in the store root) is a new key and is attempted
29
+ * immediately, and the window is deliberately bounded so a session file that
30
+ * appears a few seconds after the pane reports its id is still picked up.
31
+ *
32
+ * The memory only decelerates the ordinary cadence — the background refresh the
33
+ * daemon runs every round. It never answers a caller that is asking right now: an
34
+ * explicit read (`agent.get` / `agent.read`) and an external forced refresh pass
35
+ * `options.force` and resolve for real. The warning stays deduplicated per key,
36
+ * so forcing does not produce extra noise.
37
+ */
38
+ const DISCOVERY_RETRY_AFTER_FAILURE_MS = 60_000;
39
+ const DISCOVERY_FAILURE_CACHE_LIMIT = 512;
40
+ const failedDiscoveryAt = new Map();
41
+ const warnedDiscoveryKeys = new Set();
42
+ export function discoveryLookupKey(input) {
43
+ return [
44
+ input.agent ?? "",
45
+ input.agentSession?.kind ?? "",
46
+ input.agentSession?.source ?? "",
47
+ input.agentSession?.value ?? "",
48
+ input.homeDir ?? process.env.HOME ?? "",
49
+ ].join("\u0000");
50
+ }
51
+ /**
52
+ * True while the very same official values are still inside their retry window,
53
+ * so callers can skip the lookup the pane would otherwise repeat every round.
54
+ */
55
+ export function discoveryFailureFresh(input, now = Date.now()) {
56
+ const at = failedDiscoveryAt.get(discoveryLookupKey(input));
57
+ return at !== undefined && now - at < DISCOVERY_RETRY_AFTER_FAILURE_MS;
58
+ }
59
+ function rememberDiscoveryFailure(key, input, detail) {
60
+ failedDiscoveryAt.delete(key);
61
+ failedDiscoveryAt.set(key, Date.now());
62
+ if (failedDiscoveryAt.size > DISCOVERY_FAILURE_CACHE_LIMIT) {
63
+ const oldest = failedDiscoveryAt.keys().next();
64
+ if (!oldest.done)
65
+ failedDiscoveryAt.delete(oldest.value);
66
+ }
67
+ if (warnedDiscoveryKeys.has(key))
68
+ return;
69
+ warnedDiscoveryKeys.add(key);
70
+ if (warnedDiscoveryKeys.size > DISCOVERY_FAILURE_CACHE_LIMIT) {
71
+ const oldest = warnedDiscoveryKeys.values().next();
72
+ if (!oldest.done)
73
+ warnedDiscoveryKeys.delete(oldest.value);
74
+ }
75
+ reportMissingOfficialSession(input, detail);
76
+ }
77
+ export async function discoverAgentHistory(input, options = {}) {
78
+ const key = discoveryLookupKey(input);
79
+ if (!options.force) {
80
+ const failureAt = failedDiscoveryAt.get(key);
81
+ if (failureAt !== undefined && Date.now() - failureAt < DISCOVERY_RETRY_AFTER_FAILURE_MS) {
82
+ return null;
75
83
  }
76
84
  }
77
- const agent = input.agent?.toLowerCase() ?? input.agentSession?.agent.toLowerCase() ?? "";
78
- const candidates = [];
79
- const roleHint = roleDirectoryHint(input);
80
- if (agent === "pi") {
81
- const roots = new Set(piJsonlScanRoots(homeDir, input.herdrSessionName, roleHint));
82
- for (const root of roots) {
83
- candidates.push(...(await scanRoot(root, "pi-jsonl")));
85
+ const fail = (detail) => {
86
+ rememberDiscoveryFailure(key, input, detail);
87
+ return null;
88
+ };
89
+ const session = input.agentSession;
90
+ if (!session) {
91
+ return fail("Herdr reported no agent_session for this pane; herdsman does not guess one");
92
+ }
93
+ if (session.kind === "path") {
94
+ const resolved = safeOfficialSessionPath(session.value);
95
+ if (!resolved) {
96
+ return fail(`Herdr agent_session path is not a readable session file owned by herdsman: ${session.value}`);
84
97
  }
98
+ return {
99
+ kind: "agent_session",
100
+ path: resolved,
101
+ source: historySourceFromSessionRef(session),
102
+ value: resolved,
103
+ };
104
+ }
105
+ const resolvedById = resolveSessionFileById({
106
+ ...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
107
+ session,
108
+ });
109
+ if (resolvedById.ref)
110
+ return resolvedById.ref;
111
+ return fail(resolvedById.detail);
112
+ }
113
+ /**
114
+ * Deterministic id -> session file resolution for `agy`. It derives at most one
115
+ * candidate path from the official id alone and then verifies it: the candidate
116
+ * must be a regular file owned by herdsman inside the agent's own store root.
117
+ * Nothing is ranked, filtered by cwd, or picked from candidates.
118
+ *
119
+ * An agent without a template here is not guessed. That splits in two, and the
120
+ * warning has to say which one it is:
121
+ * - a supported agent whose official value arrived as an id (pi): herdsman has no
122
+ * id-based derivation for it, so this pane reports no history;
123
+ * - an agent herdsman does not support at all.
124
+ */
125
+ function resolveSessionFileById(input) {
126
+ const homeDir = input.homeDir ?? process.env.HOME ?? "";
127
+ const source = historySourceFromSessionRef(input.session);
128
+ const id = input.session.value;
129
+ const miss = (detail) => ({ detail, ref: null });
130
+ if (source === "antigravity-sqlite") {
131
+ // agy conversation ids are uuids; anything else cannot name a conversation
132
+ // file, and a non-uuid must never be joined into the conversations path.
133
+ if (!isUuidLike(id))
134
+ return miss(`agy conversation id is not a uuid: ${id}`);
135
+ const dir = join(homeDir, ".gemini", "antigravity-cli", "conversations");
136
+ return containedFileRef({
137
+ candidate: join(dir, `${id}.db`),
138
+ id,
139
+ label: "agy",
140
+ root: dir,
141
+ source,
142
+ });
85
143
  }
86
- if (agent === "claude") {
87
- candidates.push(...(await scanRoot(join(homeDir, ".claude", "projects"), "claude-jsonl")));
88
- }
89
- if (agent === "codex") {
90
- candidates.push(...(await scanRoot(join(homeDir, ".codex", "sessions"), "codex-jsonl")));
91
- }
92
- if (agent === "gemini") {
93
- candidates.push(...(await scanGeminiRoot(join(homeDir, ".gemini", "tmp"))));
94
- }
95
- if (agent === "opencode") {
96
- const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: null });
97
- if (ref)
98
- return ref;
144
+ if (source !== "unknown") {
145
+ return miss(`${input.session.agent} reported an id; herdsman has no id-based session-file derivation for it (only agy does)`);
99
146
  }
100
- if (agent === "grok") {
101
- const matches = await discoverGrokSessions({
102
- homeDir,
103
- cwd,
104
- sessionId: null,
105
- ...(input.grokHome === undefined ? {} : { grokHome: input.grokHome }),
106
- });
107
- candidates.push(...matches);
147
+ return miss(`${input.session.agent} is not a supported herdsman history agent (only pi and agy are)`);
148
+ }
149
+ /**
150
+ * One candidate path, verified: regular file, not a symlink, owned by herdsman,
151
+ * not writable by other principals, and really inside the agent's store root.
152
+ * Any failure is a miss (the caller reports it), never a fallback search.
153
+ */
154
+ function containedFileRef(input) {
155
+ const real = safeOfficialSessionPath(input.candidate);
156
+ if (!real) {
157
+ return {
158
+ detail: `${input.label}: ${input.candidate} is not a readable session file`,
159
+ ref: null,
160
+ };
161
+ }
162
+ if (!isInside(input.root, real)) {
163
+ return { detail: `${input.label}: ${real} escapes ${input.root}`, ref: null };
164
+ }
165
+ return {
166
+ detail: `${input.label}: resolved ${real}`,
167
+ ref: { kind: "agent_session", path: real, source: input.source, value: input.id },
168
+ };
169
+ }
170
+ function isInside(root, path) {
171
+ const realRoot = realpathOrNormalize(root);
172
+ const rest = relative(realRoot, path);
173
+ return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
174
+ }
175
+ function realpathOrNormalize(value) {
176
+ try {
177
+ return realpathSync(value);
108
178
  }
109
- if (agent === "agy" || agent === "antigravity" || agent === "antigravity_cli") {
110
- const ref = discoverAntigravitySession(homeDir, null);
111
- if (ref)
112
- return ref;
179
+ catch {
180
+ return normalize(value);
113
181
  }
114
- const ranked = candidates
115
- .filter((candidate) => normalizedCwd !== null && normalizeCwd(candidate.cwd) === normalizedCwd)
116
- .filter((candidate) => !input.occupiedSessionPaths?.has(candidate.path))
117
- .filter((candidate) => matchesRoleDirectory(candidate.path, roleHint))
118
- .filter((candidate) => input.firstSeenAtMs === undefined ||
119
- candidate.mtimeMs >= input.firstSeenAtMs - discoveryRecencyGraceMs(input))
120
- .sort((a, b) => {
121
- if (a.mtimeMs !== b.mtimeMs)
122
- return b.mtimeMs - a.mtimeMs;
123
- return a.path.localeCompare(b.path);
182
+ }
183
+ function isUuidLike(value) {
184
+ // Version-agnostic on purpose: agy conversation ids are uuids, and a uuid must
185
+ // stay pure hex+dashes so it can never act as a path.
186
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
187
+ }
188
+ function reportMissingOfficialSession(input, detail) {
189
+ console.warn("Herdsman has no usable official Herdr agent session", {
190
+ agent: input.agent,
191
+ agentSession: input.agentSession,
192
+ detail,
193
+ herdrSessionName: input.herdrSessionName ?? null,
194
+ paneId: input.paneId ?? null,
124
195
  });
125
- const best = ranked[0];
126
- return best
127
- ? { kind: "discovered_file", path: best.path, source: best.source, value: best.path }
128
- : null;
129
196
  }
197
+ /**
198
+ * Maps an official `agent_session` to the reader that can read it. Only the two
199
+ * supported agents have a reader; everything else is `unknown`, which every
200
+ * reader rejects. `source` looks like `herdr:<agent>`, so segments are matched
201
+ * whole — `herdr:copilot` contains "pi" and must never be read as a pi session.
202
+ */
130
203
  export function historySourceFromSessionRef(ref) {
131
204
  const agent = ref.agent.toLowerCase();
132
- const source = ref.source.toLowerCase();
133
- if (agent === "agy" ||
134
- agent === "antigravity" ||
135
- agent === "antigravity_cli" ||
136
- source.includes("antigravity"))
205
+ const segments = [agent, ...ref.source.toLowerCase().split(/[^a-z0-9_]+/)].filter(Boolean);
206
+ const is = (...names) => names.some((name) => segments.includes(name));
207
+ if (is("agy", "antigravity", "antigravity_cli", "antigravity-cli"))
137
208
  return "antigravity-sqlite";
138
- if (agent === "grok" || source.includes("grok"))
139
- return "grok-jsonl";
140
- if (agent === "pi" || source.includes("pi"))
209
+ if (is("pi"))
141
210
  return "pi-jsonl";
142
- if (agent === "claude" || source.includes("claude"))
143
- return "claude-jsonl";
144
- if (agent === "codex" || source.includes("codex"))
145
- return "codex-jsonl";
146
- if (agent === "opencode" || source.includes("opencode"))
147
- return "opencode-sqlite";
148
- if (agent === "gemini" || source.includes("gemini"))
149
- return "gemini-json";
150
211
  return "unknown";
151
212
  }
152
213
  export function safeAllowedSessionPath(value, homeDir) {
214
+ const real = safeRegularFile(value);
215
+ if (!real)
216
+ return null;
217
+ const homeSessionRoot = join(homeDir ?? process.env.HOME ?? "/root", ".pi/agent/sessions");
218
+ const roots = [homeSessionRoot, ...ALLOWED_SESSION_ROOTS];
219
+ return roots.some((root) => {
220
+ const rest = relative(root, real);
221
+ return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest));
222
+ })
223
+ ? real
224
+ : null;
225
+ }
226
+ /**
227
+ * File-safety gate shared by `safeOfficialSessionPath` and
228
+ * `safeAllowedSessionPath`.
229
+ *
230
+ * The raw path is checked with `lstatSync` **before** it is resolved: after
231
+ * `realpathSync` the symlink itself is gone, so an `isSymbolicLink()` check on
232
+ * the resolved path could never fire. A symlink (or anything that is not a
233
+ * regular file) at the reported path is rejected outright; the resolved target
234
+ * is then checked again, so a hard-link/symlink swap behind a directory cannot
235
+ * smuggle in a file herdsman does not own.
236
+ */
237
+ function safeRegularFile(value) {
153
238
  if (!isAbsolute(value) || value.includes(".."))
154
239
  return null;
155
- const resolved = normalize(value);
240
+ const reported = normalize(value);
156
241
  try {
157
- const real = realpathSync(resolved);
242
+ const raw = lstatSync(reported);
243
+ if (raw.isSymbolicLink() || !raw.isFile())
244
+ return null;
245
+ const real = realpathSync(reported);
158
246
  const target = lstatSync(real);
159
- if (!target.isFile() ||
160
- target.isSymbolicLink() ||
247
+ if (target.isSymbolicLink() ||
248
+ !target.isFile() ||
161
249
  target.uid !== CURRENT_EUID ||
162
250
  (target.mode & 0o022) !== 0)
163
251
  return null;
164
- const homeSessionRoot = join(homeDir ?? process.env.HOME ?? "/root", ".pi/agent/sessions");
165
- const roots = [homeSessionRoot, ...ALLOWED_SESSION_ROOTS];
166
- return roots.some((root) => {
167
- const rest = relative(root, real);
168
- return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest));
169
- })
170
- ? real
171
- : null;
252
+ return real;
172
253
  }
173
254
  catch {
174
255
  return null;
175
256
  }
176
257
  }
177
- const CURRENT_EUID = process.geteuid?.() ?? -1;
178
- function isSafeHerdrSessionSegment(name) {
179
- return name.length > 0 && name !== "." && name !== ".." && !/[\\/\0]/.test(name);
180
- }
181
- const ROLE_DIRECTORY_SEGMENT = /^role-[a-z0-9][\w-]*$/i;
182
- function roleDirectoryHint(input) {
183
- if (!input.terminalTitle)
184
- return null;
185
- for (const segment of input.terminalTitle.split(" - ")) {
186
- const trimmed = segment.trim();
187
- if (ROLE_DIRECTORY_SEGMENT.test(trimmed))
188
- return trimmed;
189
- }
190
- return null;
191
- }
192
- export function usesShortDiscoveryRecency(input) {
193
- const agent = input.agent?.toLowerCase() ?? input.agentSession?.agent.toLowerCase() ?? "";
194
- return agent === "pi" || roleDirectoryHint(input) !== null;
195
- }
196
- export function discoveryRecencyGraceMs(input) {
197
- return usesShortDiscoveryRecency(input)
198
- ? DISCOVERY_RECENCY_GRACE_MS
199
- : FALLBACK_DISCOVERY_RECENCY_GRACE_MS;
258
+ /**
259
+ * File-safety gate for an official session path reported by Herdr.
260
+ *
261
+ * Herdr only reports back the value an installed agent integration handed it
262
+ * (`pane.report_agent_session`), so the *root* of an official path is not
263
+ * guessed any more and is deliberately not whitelisted: pi keeps its sessions
264
+ * under its own agent home, and a fixed root list would silently reject those
265
+ * official values. What is still enforced is that herdsman only reads a regular,
266
+ * non-symlink file it owns and that no other principal can rewrite behind it.
267
+ * `safeAllowedSessionPath` keeps the root whitelist for the herdsman-pi
268
+ * registration protocol, where a pane claims a path itself.
269
+ */
270
+ export function safeOfficialSessionPath(value) {
271
+ return safeRegularFile(value);
200
272
  }
273
+ const CURRENT_EUID = process.geteuid?.() ?? -1;
201
274
  export function sessionPathAllowedByShape(value, homeDir) {
202
275
  if (!isAbsolute(value) || value.includes(".."))
203
276
  return false;
@@ -209,303 +282,4 @@ export function sessionPathAllowedByShape(value, homeDir) {
209
282
  return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
210
283
  });
211
284
  }
212
- function pathIsUnderAllowedSessionRoot(path) {
213
- return ALLOWED_SESSION_ROOTS.some((root) => {
214
- const rest = relative(root, path);
215
- return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
216
- });
217
- }
218
- function matchesRoleDirectory(path, roleHint) {
219
- if (!roleHint || !pathIsUnderAllowedSessionRoot(path))
220
- return true;
221
- return path.split("/").includes(roleHint);
222
- }
223
- function piJsonlScanRoots(homeDir, herdrSessionName, roleDirectory) {
224
- const roots = [];
225
- if (!roleDirectory) {
226
- roots.push(join(homeDir, ".pi", "agent", "sessions"));
227
- }
228
- if (herdrSessionName === undefined || !isSafeHerdrSessionSegment(herdrSessionName)) {
229
- return roots;
230
- }
231
- for (const allowed of ALLOWED_SESSION_ROOTS) {
232
- const scoped = normalize(join(allowed, herdrSessionName));
233
- const rest = relative(allowed, scoped);
234
- if (rest === "" || rest.startsWith("..") || isAbsolute(rest))
235
- continue;
236
- if (roleDirectory && isSafeHerdrSessionSegment(roleDirectory)) {
237
- const roleScoped = normalize(join(scoped, roleDirectory));
238
- const roleRest = relative(scoped, roleScoped);
239
- if (roleRest === "" || roleRest.startsWith("..") || isAbsolute(roleRest))
240
- continue;
241
- roots.push(roleScoped);
242
- }
243
- else {
244
- roots.push(scoped);
245
- }
246
- }
247
- return roots;
248
- }
249
- async function scanRootById(root, id, source) {
250
- if (!existsSync(root))
251
- return [];
252
- const rootStats = await stat(root).catch(() => null);
253
- if (!rootStats || rootStats.uid !== CURRENT_EUID || (rootStats.mode & 0o022) !== 0) {
254
- console.warn(`Skipping unsafe discovery root: ${root}`);
255
- return [];
256
- }
257
- const files = (await listJsonlFiles(root)).filter((path) => path.split("/").pop()?.includes(id));
258
- const candidates = [];
259
- for (const path of files) {
260
- const stats = await stat(path).catch(() => null);
261
- const linkStats = await lstat(path).catch(() => null);
262
- if (!stats?.isFile() || !linkStats?.isFile() || stats.uid !== CURRENT_EUID)
263
- continue;
264
- candidates.push({ cwd: null, mtimeMs: stats.mtimeMs, path, source });
265
- }
266
- return candidates;
267
- }
268
- function normalizeCwd(value) {
269
- if (value === null)
270
- return null;
271
- const compact = value.replace(/\/{2,}/g, "/");
272
- if (compact === "/")
273
- return compact;
274
- return compact.replace(/\/+$/, "");
275
- }
276
- async function scanRoot(root, source) {
277
- if (!existsSync(root))
278
- return [];
279
- const rootStats = await stat(root).catch(() => null);
280
- if (!rootStats || rootStats.uid !== CURRENT_EUID || (rootStats.mode & 0o022) !== 0) {
281
- console.warn(`Skipping unsafe discovery root: ${root}`);
282
- return [];
283
- }
284
- const files = await listJsonlFiles(root);
285
- const candidates = [];
286
- for (const path of files) {
287
- const stats = await stat(path).catch(() => null);
288
- const linkStats = await lstat(path).catch(() => null);
289
- if (!stats?.isFile() || !linkStats?.isFile() || stats.uid !== CURRENT_EUID)
290
- continue;
291
- candidates.push({ cwd: await readCandidateCwd(path), mtimeMs: stats.mtimeMs, path, source });
292
- }
293
- return candidates;
294
- }
295
- const MAX_DISCOVERY_DEPTH = 4;
296
- const MAX_DISCOVERY_FILES = 2000;
297
- async function listJsonlFiles(root, depth = 0, state = { count: 0 }) {
298
- if (depth > MAX_DISCOVERY_DEPTH || state.count >= MAX_DISCOVERY_FILES)
299
- return [];
300
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
301
- const files = [];
302
- for (const entry of entries) {
303
- if (state.count >= MAX_DISCOVERY_FILES)
304
- break;
305
- const path = join(root, entry.name);
306
- if (entry.isDirectory())
307
- files.push(...(await listJsonlFiles(path, depth + 1, state)));
308
- if (entry.isFile() && entry.name.endsWith(".jsonl")) {
309
- const candidate = join(root, entry.name);
310
- if (!lstatSync(candidate, { throwIfNoEntry: false })?.isFile())
311
- continue;
312
- files.push(candidate);
313
- state.count += 1;
314
- }
315
- }
316
- return files;
317
- }
318
- async function readCandidateCwd(path) {
319
- const input = createReadStream(path, { encoding: "utf8", start: 0, end: 256 * 1024 - 1 });
320
- input.on("error", () => { });
321
- const lines = createInterface({ input, crlfDelay: Infinity });
322
- let inspected = 0;
323
- try {
324
- for await (const line of lines) {
325
- if (line.trim().length === 0)
326
- continue;
327
- inspected += 1;
328
- try {
329
- const parsed = JSON.parse(line);
330
- const record = recordValue(parsed);
331
- const cwd = stringValue(record.cwd) ?? stringValue(record.foreground_cwd);
332
- if (cwd)
333
- return cwd;
334
- const payload = recordValue(record.payload);
335
- const payloadCwd = stringValue(payload.cwd) ?? stringValue(payload.foreground_cwd);
336
- if (payloadCwd)
337
- return payloadCwd;
338
- const message = recordValue(record.message);
339
- const nestedCwd = stringValue(message.cwd) ?? stringValue(message.foreground_cwd);
340
- if (nestedCwd)
341
- return nestedCwd;
342
- }
343
- catch {
344
- // Ignore malformed candidate records, preserving existing matching semantics.
345
- }
346
- if (inspected >= 100)
347
- break;
348
- }
349
- }
350
- finally {
351
- lines.close();
352
- input.destroy();
353
- }
354
- return null;
355
- }
356
- async function scanGeminiRoot(root) {
357
- if (!existsSync(root))
358
- return [];
359
- const projectDirs = await listGeminiProjectDirs(root);
360
- const candidates = [];
361
- for (const projectDir of projectDirs) {
362
- const cwd = (await readFile(join(projectDir, ".project_root"), "utf8").catch(() => "")).trim() || null;
363
- const sessions = await listGeminiSessionFiles(join(projectDir, "chats"));
364
- for (const path of sessions) {
365
- const stats = await stat(path).catch(() => null);
366
- if (!stats?.isFile())
367
- continue;
368
- candidates.push({ cwd, mtimeMs: stats.mtimeMs, path, source: "gemini-json" });
369
- }
370
- }
371
- return candidates;
372
- }
373
- async function listGeminiProjectDirs(root) {
374
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
375
- const dirs = [];
376
- for (const entry of entries) {
377
- const path = join(root, entry.name);
378
- if (!entry.isDirectory())
379
- continue;
380
- if (existsSync(join(path, ".project_root")))
381
- dirs.push(path);
382
- }
383
- return dirs;
384
- }
385
- async function listGeminiSessionFiles(chatsDir) {
386
- const entries = await readdir(chatsDir, { withFileTypes: true }).catch(() => []);
387
- return entries
388
- .filter((entry) => entry.isFile() && entry.name.startsWith("session-") && entry.name.endsWith(".json"))
389
- .map((entry) => join(chatsDir, entry.name));
390
- }
391
- function discoverGrokSessions(input) {
392
- const root = join(input.grokHome ?? process.env.GROK_HOME ?? join(input.homeDir, ".grok"), "sessions");
393
- return listGrokCandidates(root, input.cwd, input.sessionId);
394
- }
395
- async function listGrokCandidates(root, cwd, sessionId) {
396
- if (!existsSync(root))
397
- return [];
398
- const rootStats = await stat(root).catch(() => null);
399
- if (!rootStats || rootStats.uid !== CURRENT_EUID || (rootStats.mode & 0o022) !== 0)
400
- return [];
401
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
402
- const result = [];
403
- for (const encoded of entries) {
404
- if (!encoded.isDirectory())
405
- continue;
406
- let decodedCwd;
407
- try {
408
- decodedCwd = decodeURIComponent(encoded.name);
409
- }
410
- catch {
411
- continue;
412
- }
413
- if (cwd && normalizeCwd(decodedCwd) !== normalizeCwd(cwd))
414
- continue;
415
- const sessions = await readdir(join(root, encoded.name), { withFileTypes: true }).catch(() => []);
416
- for (const session of sessions) {
417
- if (!session.isDirectory() ||
418
- !isUuid(session.name) ||
419
- (sessionId && session.name !== sessionId))
420
- continue;
421
- const path = join(root, encoded.name, session.name, "chat_history.jsonl");
422
- const link = await lstat(path).catch(() => null);
423
- const stats = await stat(path).catch(() => null);
424
- if (!link?.isFile() || !stats?.isFile() || stats.uid !== CURRENT_EUID)
425
- continue;
426
- result.push({ cwd: decodedCwd, mtimeMs: stats.mtimeMs, path, source: "grok-jsonl" });
427
- }
428
- }
429
- return result;
430
- }
431
- function selectUniqueCandidate(candidates, cwd, occupied) {
432
- const filtered = candidates.filter((item) => !occupied?.has(item.path));
433
- if (cwd) {
434
- const matching = filtered.filter((item) => normalizeCwd(item.cwd) === normalizeCwd(cwd));
435
- return matching.length === 1 ? (matching[0] ?? null) : null;
436
- }
437
- return filtered.length === 1 ? (filtered[0] ?? null) : null;
438
- }
439
- function isUuid(value) {
440
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
441
- }
442
- function discoverAntigravitySession(homeDir, sessionId) {
443
- if (sessionId && !isUuid(sessionId))
444
- return null;
445
- const dir = join(homeDir, ".gemini", "antigravity-cli", "conversations");
446
- if (sessionId) {
447
- const path = join(dir, `${sessionId}.db`);
448
- return safeConversationDb(path, sessionId)
449
- ? { kind: "discovered_file", path, source: "antigravity-sqlite", value: sessionId }
450
- : null;
451
- }
452
- return null;
453
- }
454
- function safeConversationDb(path, sessionId) {
455
- if (!isUuid(sessionId) || !existsSync(path))
456
- return false;
457
- try {
458
- const stats = lstatSync(path);
459
- return stats.isFile() && (stats.mode & 0o022) === 0;
460
- }
461
- catch {
462
- return false;
463
- }
464
- }
465
- function discoverOpenCodeSession(input) {
466
- const dbPath = resolveOpenCodeDbPath(input.homeDir);
467
- if (!existsSync(dbPath))
468
- return null;
469
- let sqlite = null;
470
- try {
471
- sqlite = new DatabaseSync(dbPath, { readOnly: true });
472
- if (input.sessionId) {
473
- const row = sqlite
474
- .prepare("select id from session where id = ? limit 1")
475
- .get(input.sessionId);
476
- return row
477
- ? { kind: "discovered_file", path: dbPath, source: "opencode-sqlite", value: row.id }
478
- : null;
479
- }
480
- if (!input.cwd)
481
- return null;
482
- const row = sqlite
483
- .prepare("select id from session where directory = ? order by time_updated desc limit 1")
484
- .get(input.cwd);
485
- return row
486
- ? { kind: "discovered_file", path: dbPath, source: "opencode-sqlite", value: row.id }
487
- : null;
488
- }
489
- catch {
490
- return null;
491
- }
492
- finally {
493
- sqlite?.close();
494
- }
495
- }
496
- function resolveOpenCodeDbPath(homeDir) {
497
- const override = process.env.OPENCODE_DB;
498
- if (override && override !== ":memory:") {
499
- return override.startsWith("/")
500
- ? override
501
- : join(homeDir, ".local", "share", "opencode", override);
502
- }
503
- return join(homeDir, ".local", "share", "opencode", "opencode.db");
504
- }
505
- function recordValue(value) {
506
- return typeof value === "object" && value !== null ? value : {};
507
- }
508
- function stringValue(value) {
509
- return typeof value === "string" && value.length > 0 ? value : null;
510
- }
511
285
  //# sourceMappingURL=discovery.js.map