@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.
- package/README.md +2 -2
- package/dist/src/agent-history/discovery.d.ts +47 -11
- package/dist/src/agent-history/discovery.d.ts.map +1 -1
- package/dist/src/agent-history/discovery.js +242 -468
- package/dist/src/agent-history/discovery.js.map +1 -1
- package/dist/src/agent-history/service.d.ts +4 -7
- package/dist/src/agent-history/service.d.ts.map +1 -1
- package/dist/src/agent-history/service.js +46 -30
- package/dist/src/agent-history/service.js.map +1 -1
- package/dist/src/daemon/agent-event-reconciler.js +6 -10
- package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
- package/dist/src/daemon/herdr-session-watch-manager.js +2 -2
- package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
- package/dist/src/daemon/observability-server.js +6 -6
- package/dist/src/daemon/observability-server.js.map +1 -1
- package/dist/src/db/agents.d.ts +0 -2
- package/dist/src/db/agents.d.ts.map +1 -1
- package/dist/src/db/agents.js +14 -57
- package/dist/src/db/agents.js.map +1 -1
- package/dist/src/herdr/pane-identity-resolver.d.ts +11 -0
- package/dist/src/herdr/pane-identity-resolver.d.ts.map +1 -1
- package/dist/src/herdr/pane-identity-resolver.js +27 -15
- package/dist/src/herdr/pane-identity-resolver.js.map +1 -1
- package/dist/src/herdr/session-list.d.ts.map +1 -1
- package/dist/src/herdr/session-list.js +4 -2
- package/dist/src/herdr/session-list.js.map +1 -1
- package/dist/src/observability/agent-context-service.d.ts +4 -10
- package/dist/src/observability/agent-context-service.d.ts.map +1 -1
- package/dist/src/observability/agent-context-service.js +37 -58
- package/dist/src/observability/agent-context-service.js.map +1 -1
- package/dist/src/observability/agent-index-service.d.ts.map +1 -1
- package/dist/src/observability/agent-index-service.js +15 -22
- package/dist/src/observability/agent-index-service.js.map +1 -1
- package/dist/src/observability/contracts.d.ts +1 -2
- package/dist/src/observability/contracts.d.ts.map +1 -1
- package/dist/src/observability/contracts.js.map +1 -1
- package/package.json +1 -1
- package/dist/src/agent-history/claude-reader.d.ts +0 -10
- package/dist/src/agent-history/claude-reader.d.ts.map +0 -1
- package/dist/src/agent-history/claude-reader.js +0 -90
- package/dist/src/agent-history/claude-reader.js.map +0 -1
- package/dist/src/agent-history/codex-reader.d.ts +0 -10
- package/dist/src/agent-history/codex-reader.d.ts.map +0 -1
- package/dist/src/agent-history/codex-reader.js +0 -119
- package/dist/src/agent-history/codex-reader.js.map +0 -1
- package/dist/src/agent-history/gemini-reader.d.ts +0 -10
- package/dist/src/agent-history/gemini-reader.d.ts.map +0 -1
- package/dist/src/agent-history/gemini-reader.js +0 -96
- package/dist/src/agent-history/gemini-reader.js.map +0 -1
- package/dist/src/agent-history/grok-reader.d.ts +0 -11
- package/dist/src/agent-history/grok-reader.d.ts.map +0 -1
- package/dist/src/agent-history/grok-reader.js +0 -54
- package/dist/src/agent-history/grok-reader.js.map +0 -1
- package/dist/src/agent-history/opencode-reader.d.ts +0 -10
- package/dist/src/agent-history/opencode-reader.d.ts.map +0 -1
- package/dist/src/agent-history/opencode-reader.js +0 -90
- package/dist/src/agent-history/opencode-reader.js.map +0 -1
|
@@ -1,203 +1,276 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
87
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
if (ref)
|
|
112
|
-
return ref;
|
|
179
|
+
catch {
|
|
180
|
+
return normalize(value);
|
|
113
181
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
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
|
|
240
|
+
const reported = normalize(value);
|
|
156
241
|
try {
|
|
157
|
-
const
|
|
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 (
|
|
160
|
-
target.
|
|
247
|
+
if (target.isSymbolicLink() ||
|
|
248
|
+
!target.isFile() ||
|
|
161
249
|
target.uid !== CURRENT_EUID ||
|
|
162
250
|
(target.mode & 0o022) !== 0)
|
|
163
251
|
return null;
|
|
164
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return
|
|
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
|