granttap-mcp 0.8.84 → 0.8.86
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/apps/bridge/src/machine-load/mcp/scan-cost.ts +10 -0
- package/apps/bridge/src/mesh/catalog/README.md +10 -0
- package/apps/bridge/src/mesh/catalog/index.ts +1 -0
- package/apps/bridge/src/mesh/catalog/repository-inspection.ts +49 -0
- package/apps/bridge/src/mesh/runtime/graph/binding-sync.ts +8 -8
- package/apps/bridge/src/monitor/capability-publication/README.md +13 -0
- package/apps/bridge/src/monitor/capability-publication/index.ts +33 -0
- package/apps/bridge/src/monitor/support/runtime.ts +39 -30
- package/apps/bridge/src/session-catalog/README.md +9 -1
- package/apps/bridge/src/sessions/README.md +5 -0
- package/apps/bridge/src/sessions/catalog.ts +155 -0
- package/apps/bridge/src/sessions/cooperative-scan.ts +21 -0
- package/apps/bridge/src/sessions/index.ts +4 -124
- package/apps/bridge/src/sessions/scan/claude/scan.ts +19 -4
- package/apps/bridge/src/sessions/scan/codex/scan.ts +11 -6
- package/apps/bridge/src/sessions/support/repository-checkout/index.ts +5 -6
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client.ts +10 -11
- package/packages/core/relay-inbound/index.ts +6 -0
- package/packages/core/socket-lifecycle/README.md +3 -0
- package/packages/core/socket-lifecycle/connection-notifications.ts +18 -0
- package/packages/core/socket-lifecycle/index.ts +1 -0
|
@@ -23,3 +23,13 @@ export function timedProviderScan<T extends { sessions: unknown[] }>(
|
|
|
23
23
|
});
|
|
24
24
|
return result;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/** Includes elapsed time across cooperative yields; selected capability is never usage. */
|
|
28
|
+
export async function timedProviderScanAsync<T extends { sessions: unknown[] }>(
|
|
29
|
+
agent: string, scan: () => Promise<T>,
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
const result = await scan();
|
|
33
|
+
samples.set(agent, { durationMs: Date.now() - startedAt, sessions: result.sessions.length });
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
@@ -17,6 +17,16 @@ negative probes nor old repository identities persist across refreshes.
|
|
|
17
17
|
Behavior tests: `tests/agentic/codex-workspace-binding.test.ts` and
|
|
18
18
|
`tests/project/project-mesh-core.test.ts`.
|
|
19
19
|
|
|
20
|
+
## Background Graph admission
|
|
21
|
+
|
|
22
|
+
`inspectRepositoryAsync` provides the same repository facts without blocking
|
|
23
|
+
relay callbacks. Native Git subprocesses are limited to two across the process.
|
|
24
|
+
Concurrent inspections of the same path share their in-flight result; a later
|
|
25
|
+
inspection verifies the checkout again. Missing Git facts remain unknown.
|
|
26
|
+
|
|
27
|
+
Tests for asynchronous inspection live in `tests/`; Graph admission also has
|
|
28
|
+
`runtime/graph/tests/responsiveness/checkout-inspection.test.ts`.
|
|
29
|
+
|
|
20
30
|
## License
|
|
21
31
|
|
|
22
32
|
GrantTap Commercial Source License. See [LICENSE](../../../../../LICENSE).
|
|
@@ -19,6 +19,7 @@ import { readIntegrationMap } from "../observed/integration-map";
|
|
|
19
19
|
import type { MeshStore } from "../store";
|
|
20
20
|
import { queueProjectBindingSync } from "../../engine/runtime/engine-projects";
|
|
21
21
|
import { isCursorTaskCloneId } from "../../sessions/cursor/catalog";
|
|
22
|
+
export { inspectRepositoryAsync } from "./repository-inspection";
|
|
22
23
|
|
|
23
24
|
export type RepositoryFacts = {
|
|
24
25
|
root: string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { canonicalRepositoryIdentity, sanitizedRepositoryRemote } from "../identity";
|
|
3
|
+
import type { RepositoryFacts } from ".";
|
|
4
|
+
|
|
5
|
+
let running = 0;
|
|
6
|
+
const waiting: Array<() => void> = [];
|
|
7
|
+
const pending = new Map<string, Promise<RepositoryFacts>>();
|
|
8
|
+
|
|
9
|
+
/** Native read-only Git work stays bounded without blocking relay callbacks. */
|
|
10
|
+
function git(cwd: string, args: string[]): Promise<string | undefined> {
|
|
11
|
+
return new Promise(resolve => {
|
|
12
|
+
const run = () => {
|
|
13
|
+
running++;
|
|
14
|
+
const done = (error: Error | null, stdout = "") => {
|
|
15
|
+
running--;
|
|
16
|
+
waiting.shift()?.();
|
|
17
|
+
resolve(error ? undefined : stdout.trim() || undefined);
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
execFile("git", ["-C", cwd, ...args], {
|
|
21
|
+
encoding: "utf8", timeout: 2_000, maxBuffer: 1024 * 1024, windowsHide: true,
|
|
22
|
+
}, done);
|
|
23
|
+
} catch { done(new Error("Repository command could not start")); }
|
|
24
|
+
};
|
|
25
|
+
if (running < 2) run(); else waiting.push(run);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function inspect(cwd: string): Promise<RepositoryFacts> {
|
|
30
|
+
const worktree = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
31
|
+
const root = worktree ?? cwd;
|
|
32
|
+
const [rawRemote, revision, branch] = await Promise.all([
|
|
33
|
+
git(root, ["remote", "get-url", "origin"]),
|
|
34
|
+
git(root, ["rev-parse", "HEAD"]),
|
|
35
|
+
git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
|
|
36
|
+
]);
|
|
37
|
+
return { root, worktree, revision, branch,
|
|
38
|
+
baseRemote: rawRemote ? sanitizedRepositoryRemote(rawRemote) : undefined,
|
|
39
|
+
canonicalRepositoryId: canonicalRepositoryIdentity(rawRemote, root) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Share only in-flight observations; a later refresh verifies the checkout again. */
|
|
43
|
+
export function inspectRepositoryAsync(cwd: string): Promise<RepositoryFacts> {
|
|
44
|
+
const active = pending.get(cwd);
|
|
45
|
+
if (active) return active;
|
|
46
|
+
const next = inspect(cwd).finally(() => { pending.delete(cwd); });
|
|
47
|
+
pending.set(cwd, next);
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import type { MeshSnapshot, ProjectRepositoryGraph } from "../../../../../../packages/protocol/schema";
|
|
3
3
|
import { queueProjectBindingSync } from "../../../engine/runtime/engine-projects";
|
|
4
|
-
import {
|
|
4
|
+
import { inspectRepositoryAsync, type RepositoryFacts } from "../../catalog";
|
|
5
5
|
import { computerId } from "../../identity/computer";
|
|
6
6
|
import { matchesLocalCheckout } from "./checkout-identity";
|
|
7
7
|
|
|
@@ -18,18 +18,18 @@ export async function refreshLocalGraphBindings(
|
|
|
18
18
|
snapshot: MeshSnapshot,
|
|
19
19
|
dependencies: {
|
|
20
20
|
endpoint: () => string;
|
|
21
|
-
inspect:
|
|
21
|
+
inspect: (cwd: string) => RepositoryFacts | Promise<RepositoryFacts>;
|
|
22
22
|
sync: typeof queueProjectBindingSync;
|
|
23
|
-
} = { endpoint: computerId, inspect:
|
|
23
|
+
} = { endpoint: computerId, inspect: inspectRepositoryAsync, sync: queueProjectBindingSync },
|
|
24
24
|
): Promise<ProjectRepositoryGraph[]> {
|
|
25
25
|
const localEndpoint = dependencies.endpoint();
|
|
26
26
|
const candidates = (snapshot.bindings ?? []).filter((binding) =>
|
|
27
27
|
binding.endpointId === localEndpoint && binding.available && binding.localPathHint);
|
|
28
28
|
const failures: ProjectRepositoryGraph[] = [];
|
|
29
|
-
const checkouts = candidates.
|
|
30
|
-
const facts = readCheckout(dependencies.inspect, binding.localPathHint!);
|
|
29
|
+
const checkouts = (await Promise.all(candidates.map(async binding => {
|
|
30
|
+
const facts = await readCheckout(dependencies.inspect, binding.localPathHint!);
|
|
31
31
|
return facts ? [{ binding, facts, real: realpathSync(facts.root) }] : [];
|
|
32
|
-
});
|
|
32
|
+
}))).flat();
|
|
33
33
|
await Promise.all(checkouts.map(async ({ binding, facts, real }) => {
|
|
34
34
|
const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
|
|
35
35
|
if (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity) {
|
|
@@ -58,9 +58,9 @@ export async function refreshLocalGraphBindings(
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** A Git checkout rooted exactly at the bound path, or nothing a Graph can be built from. */
|
|
61
|
-
function readCheckout(inspect:
|
|
61
|
+
async function readCheckout(inspect: (cwd: string) => RepositoryFacts | Promise<RepositoryFacts>, path: string) {
|
|
62
62
|
try {
|
|
63
|
-
const facts = inspect(path);
|
|
63
|
+
const facts = await inspect(path);
|
|
64
64
|
if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)) return undefined;
|
|
65
65
|
return facts;
|
|
66
66
|
} catch {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Usage publication
|
|
2
|
+
|
|
3
|
+
Public entry: `index.ts`. Complete capability totals are computed from the live
|
|
4
|
+
catalog and full discovered history. This work has one independent in-flight
|
|
5
|
+
publication per relay client. A slow history read or encrypted fan-out does not
|
|
6
|
+
hold the current chat catalog. Successful reports retain their existing cadence;
|
|
7
|
+
failures retry on a later catalog publication. Closing the monitor prevents
|
|
8
|
+
further queued reads and sends. Counts and unknown usage semantics are unchanged.
|
|
9
|
+
Concurrent catalog and Usage requests share one in-flight history read. The
|
|
10
|
+
history cache's lifetime begins when that read completes.
|
|
11
|
+
|
|
12
|
+
Behavior tests live in `tests/`.
|
|
13
|
+
Licensed under the GrantTap Commercial Source License in the repository root.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { setImmediate } from "node:timers/promises";
|
|
2
|
+
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
3
|
+
import type { SessionInfo, SessionsStatus } from "../../../../../packages/protocol/schema";
|
|
4
|
+
import { scanCapabilityUsage, scopeCapabilityUsageToRoom } from "../../sessions";
|
|
5
|
+
|
|
6
|
+
type Publication = { running: boolean; closed: boolean; lastPublishedAt: number };
|
|
7
|
+
const publications = new WeakMap<RelayClient, Publication>();
|
|
8
|
+
|
|
9
|
+
/** A slow usage fan-out never occupies the current chat catalog's publication slot. */
|
|
10
|
+
export function publishMonitorCapabilities(client: RelayClient, status: SessionsStatus,
|
|
11
|
+
history: () => Promise<SessionInfo[]>): void {
|
|
12
|
+
const state = publications.get(client) ?? { running: false, closed: false, lastPublishedAt: 0 };
|
|
13
|
+
publications.set(client, state);
|
|
14
|
+
if (state.running || state.closed || Date.now() - state.lastPublishedAt < 30_000) return;
|
|
15
|
+
state.running = true;
|
|
16
|
+
const work = (async () => {
|
|
17
|
+
await setImmediate();
|
|
18
|
+
if (state.closed || !client.isConnected) return;
|
|
19
|
+
const older = await history();
|
|
20
|
+
if (state.closed || !client.isConnected) return;
|
|
21
|
+
const usage = scanCapabilityUsage([...status.sessions, ...older]);
|
|
22
|
+
await client.send(scopeCapabilityUsageToRoom(usage, client.room), "phone",
|
|
23
|
+
{ ttlMs: 90_000, reliable: false });
|
|
24
|
+
state.lastPublishedAt = Date.now();
|
|
25
|
+
})();
|
|
26
|
+
void work.catch(() => {}).finally(() => { state.running = false; });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stopMonitorCapabilities(client: RelayClient): void {
|
|
30
|
+
const state = publications.get(client);
|
|
31
|
+
if (state) state.closed = true;
|
|
32
|
+
publications.delete(client);
|
|
33
|
+
}
|
|
@@ -23,7 +23,7 @@ import { publishedCatalog, rememberPublishedCatalog, resumePublishedChats,
|
|
|
23
23
|
publishCatalogHeads, stopCatalogHeads } from "../../session-catalog";
|
|
24
24
|
import { createMachineLoadPublisher } from "../../machine-load";
|
|
25
25
|
import { startMachineLoadLoop } from "../../machine-load/host/loop";
|
|
26
|
-
import {
|
|
26
|
+
import { scanSessionHistoryCooperatively, scanSessionsCooperatively, TOKEN_WINDOW_HOURS } from "../../sessions";
|
|
27
27
|
import { boundedCatalogHistory, INTERVAL_MS, publishSessionEvents } from "../handlers/catalog";
|
|
28
28
|
import { sweepAttachments } from "../handlers/receipts";
|
|
29
29
|
import { handleMonitorMessage } from "../handlers/inbound";
|
|
@@ -32,6 +32,7 @@ import { decorateTaskOriginCatalog } from '../../task-origins';
|
|
|
32
32
|
const HISTORY_INTERVAL_MS = 60_000;
|
|
33
33
|
const resumedHeads = new WeakSet<RelayClient>();
|
|
34
34
|
import { publishMonitorMeshes, stopMonitorMeshes } from "../mesh-publication";
|
|
35
|
+
import { publishMonitorCapabilities, stopMonitorCapabilities } from "../capability-publication";
|
|
35
36
|
|
|
36
37
|
export type SessionMonitor = {
|
|
37
38
|
publish: () => Promise<void>;
|
|
@@ -71,23 +72,23 @@ function decorateSessions(
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function monitorSnapshot(input: {
|
|
75
|
+
async function monitorSnapshot(input: {
|
|
75
76
|
includeHistory: boolean;
|
|
76
77
|
forceHistory: boolean;
|
|
77
78
|
subscriptions: Set<string>;
|
|
78
|
-
history: (force?: boolean) => SessionsStatus["sessions"]
|
|
79
|
-
}): SessionsStatus {
|
|
79
|
+
history: (force?: boolean) => Promise<SessionsStatus["sessions"]>;
|
|
80
|
+
}): Promise<SessionsStatus> {
|
|
80
81
|
const { includeHistory, forceHistory, subscriptions, history } = input;
|
|
81
82
|
sweepAttachments();
|
|
82
83
|
if (loadRuntimeConfig().meshEnabled) localMeshStore().flush();
|
|
83
|
-
const { sessions, tokensRecent } =
|
|
84
|
+
const { sessions, tokensRecent } = await scanSessionsCooperatively();
|
|
84
85
|
const runtime = loadRuntimeConfig();
|
|
85
86
|
return {
|
|
86
87
|
type: "sessions.status",
|
|
87
88
|
machine: computerDisplayName(),
|
|
88
89
|
sessions: decorateSessions(sessions, subscriptions),
|
|
89
90
|
history: includeHistory
|
|
90
|
-
? decorateSessions(boundedCatalogHistory(history(forceHistory), subscriptions), subscriptions)
|
|
91
|
+
? decorateSessions(boundedCatalogHistory(await history(forceHistory), subscriptions), subscriptions)
|
|
91
92
|
: undefined,
|
|
92
93
|
tokensRecent,
|
|
93
94
|
tokenWindowHours: TOKEN_WINDOW_HOURS,
|
|
@@ -113,21 +114,24 @@ async function publishMonitorTick(input: {
|
|
|
113
114
|
client: RelayClient;
|
|
114
115
|
leadership: ReturnType<typeof monitorLeadership>;
|
|
115
116
|
subscriptions: Set<string>;
|
|
116
|
-
history: (force?: boolean) => SessionsStatus["sessions"]
|
|
117
|
-
clocks: { lastHistoryPublishedAt: number
|
|
117
|
+
history: (force?: boolean) => Promise<SessionsStatus["sessions"]>;
|
|
118
|
+
clocks: { lastHistoryPublishedAt: number };
|
|
118
119
|
loadLoop: { updateStatus: (status: SessionsStatus) => void };
|
|
119
120
|
forceHistory: boolean;
|
|
120
|
-
|
|
121
|
+
active: () => boolean;
|
|
122
|
+
}): Promise<{ lastHistoryPublishedAt: number }> {
|
|
121
123
|
const { client, leadership, subscriptions, history, clocks, loadLoop, forceHistory } = input;
|
|
122
|
-
let { lastHistoryPublishedAt
|
|
123
|
-
if (!client.isConnected) return { lastHistoryPublishedAt
|
|
124
|
+
let { lastHistoryPublishedAt } = clocks;
|
|
125
|
+
if (!client.isConnected) return { lastHistoryPublishedAt };
|
|
124
126
|
await publishHeartbeat(client).catch(() => {});
|
|
125
|
-
if (!
|
|
127
|
+
if (!input.active()) return { lastHistoryPublishedAt };
|
|
128
|
+
if (!leadership.acquire()) return { lastHistoryPublishedAt };
|
|
126
129
|
await client.send(approvalsStatus(), "phone", { ttlMs: INTERVAL_MS * 3, reliable: false });
|
|
127
130
|
const initial = lastHistoryPublishedAt === 0;
|
|
128
131
|
const includeHistory = forceHistory || (!initial && Date.now() - lastHistoryPublishedAt >= HISTORY_INTERVAL_MS);
|
|
129
|
-
const
|
|
130
|
-
|
|
132
|
+
const snapshot = await monitorSnapshot({ includeHistory, forceHistory, subscriptions, history });
|
|
133
|
+
if (!input.active()) return { lastHistoryPublishedAt };
|
|
134
|
+
const status = decorateTaskOriginCatalog(snapshot, client.room);
|
|
131
135
|
rememberPublishedCatalog(client, status);
|
|
132
136
|
await client.send(status, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false });
|
|
133
137
|
if (initial && !resumedHeads.has(client)) {
|
|
@@ -143,15 +147,8 @@ async function publishMonitorTick(input: {
|
|
|
143
147
|
void refreshMcpLoad(status.sessions).catch(() => {});
|
|
144
148
|
publishCatalogHeads(client, status, subscriptions, publishSessionEvents);
|
|
145
149
|
if (loadRuntimeConfig().meshEnabled) publishMonitorMeshes(client, status);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
...status.sessions,
|
|
149
|
-
...history(false),
|
|
150
|
-
]);
|
|
151
|
-
await client.send(scopeCapabilityUsageToRoom(usage, client.room), "phone", { ttlMs: 90_000, reliable: false });
|
|
152
|
-
lastCapabilityPublishedAt = Date.now();
|
|
153
|
-
}
|
|
154
|
-
return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
|
|
150
|
+
publishMonitorCapabilities(client, status, () => history(false));
|
|
151
|
+
return { lastHistoryPublishedAt };
|
|
155
152
|
}
|
|
156
153
|
|
|
157
154
|
/**
|
|
@@ -165,8 +162,9 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
165
162
|
primeSessionKeys(client);
|
|
166
163
|
publishedCatalog(client);
|
|
167
164
|
let historyCache: { generatedAt: number; sessions: SessionsStatus["sessions"] } | undefined;
|
|
165
|
+
let historyRead: Promise<SessionsStatus["sessions"]> | undefined;
|
|
168
166
|
let lastHistoryPublishedAt = 0;
|
|
169
|
-
let
|
|
167
|
+
let closed = false;
|
|
170
168
|
const publishMachineLoad = createMachineLoadPublisher({
|
|
171
169
|
log: (line) => process.stderr.write(`[monitor] ${line}\n`),
|
|
172
170
|
});
|
|
@@ -185,20 +183,26 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
185
183
|
},
|
|
186
184
|
});
|
|
187
185
|
|
|
188
|
-
const history = (force = false): SessionsStatus["sessions"] => {
|
|
189
|
-
if (
|
|
190
|
-
|
|
186
|
+
const history = (force = false): Promise<SessionsStatus["sessions"]> => {
|
|
187
|
+
if (historyRead) return historyRead;
|
|
188
|
+
if (!force && historyCache && Date.now() - historyCache.generatedAt <= 60_000) {
|
|
189
|
+
return Promise.resolve(historyCache.sessions);
|
|
191
190
|
}
|
|
192
|
-
|
|
191
|
+
historyRead = scanSessionHistoryCooperatively().then(sessions => {
|
|
192
|
+
historyCache = { generatedAt: Date.now(), sessions };
|
|
193
|
+
return sessions;
|
|
194
|
+
}).finally(() => { historyRead = undefined; });
|
|
195
|
+
return historyRead;
|
|
193
196
|
};
|
|
194
197
|
|
|
195
198
|
const publish = singleFlightPublisher(async (forceHistory: boolean): Promise<void> => {
|
|
199
|
+
if (closed) return;
|
|
196
200
|
const next = await publishMonitorTick({
|
|
197
201
|
client, leadership, subscriptions, history, loadLoop, forceHistory,
|
|
198
|
-
|
|
202
|
+
active: () => !closed,
|
|
203
|
+
clocks: { lastHistoryPublishedAt },
|
|
199
204
|
});
|
|
200
205
|
lastHistoryPublishedAt = next.lastHistoryPublishedAt;
|
|
201
|
-
lastCapabilityPublishedAt = next.lastCapabilityPublishedAt;
|
|
202
206
|
});
|
|
203
207
|
|
|
204
208
|
const off = client.onMessage(async (payload: Payload, peerPublicKey) => {
|
|
@@ -213,6 +217,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
213
217
|
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
214
218
|
});
|
|
215
219
|
|
|
220
|
+
const offConnected = client.onConnected?.(() => publish().catch(() => {}));
|
|
221
|
+
|
|
216
222
|
const stopCatalog = startPublishLoop({
|
|
217
223
|
connected: () => client.isConnected,
|
|
218
224
|
intervalMs: INTERVAL_MS,
|
|
@@ -233,8 +239,11 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
233
239
|
publish: () => publish(false),
|
|
234
240
|
isLeader: () => leadership.acquire(),
|
|
235
241
|
close: () => {
|
|
242
|
+
closed = true;
|
|
243
|
+
offConnected?.();
|
|
236
244
|
stopCatalog();
|
|
237
245
|
stopCatalogHeads(client);
|
|
246
|
+
stopMonitorCapabilities(client);
|
|
238
247
|
stopMonitorMeshes(client);
|
|
239
248
|
stopHeartbeat();
|
|
240
249
|
loadLoop.stop();
|
|
@@ -18,8 +18,16 @@ Tests live in `tests/`. This runtime module uses the repository GrantTap Commerc
|
|
|
18
18
|
|
|
19
19
|
Engine binding recovery waits for the first connected catalog publication before projecting repository graphs. A cold Engine cannot hold the initial relay handshake behind restored Mesh analysis.
|
|
20
20
|
|
|
21
|
-
Codex discovery
|
|
21
|
+
Codex and Claude discovery share repeated checkout verification only within one scan. The next scan verifies repository identity again; no long-lived checkout cache can cross a repository change. This keeps startup catalog delivery ahead of repeated Git subprocesses.
|
|
22
22
|
|
|
23
23
|
Subscribed transcript heads use a separate, single-flight transfer loop. A stalled head never occupies the current catalog publication slot; stopping the monitor cancels further queued reads. The iPhone keeps its historical backfill independent of both loops.
|
|
24
24
|
|
|
25
25
|
Authenticated subscription and transcript requests pass the verified requester key to the encrypted reply. A requested page does not wait for copies addressed to other paired controllers. Shared catalogue and chat updates remain available to passive controllers.
|
|
26
|
+
|
|
27
|
+
Periodic native discovery yields between small timed batches of source files.
|
|
28
|
+
Warm metadata-cache hits share a batch instead of waiting behind one background
|
|
29
|
+
Project projection for every unchanged file. The same provider parsers
|
|
30
|
+
and metadata caches serve synchronous CLI readers and cooperative live-monitor
|
|
31
|
+
readers. Checkout observations are scoped to one asynchronous scan, so unrelated
|
|
32
|
+
requests cannot inherit an old directory result. Socket recovery immediately
|
|
33
|
+
republishes the current catalog instead of waiting for the next periodic tick.
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Public entry: `index.ts`. Provider readers discover local executions and keep
|
|
4
4
|
their native source identity for explicit chat and nested-conversation requests.
|
|
5
5
|
`session-activity.ts` owns provider selection and bounded child replies.
|
|
6
|
+
`catalog.ts` owns provider selection and bounded live/history catalogs. The
|
|
7
|
+
monitor uses cooperative Codex and Claude readers, yielding between timed batches
|
|
8
|
+
so discovery cannot monopolize relay callbacks. Synchronous CLI readers use the
|
|
9
|
+
same parsers. Published native bindings stay readable until a complete scan
|
|
10
|
+
replaces them; checkout verification caches belong to one scan across its yields.
|
|
6
11
|
|
|
7
12
|
Claude child reads use only the requested owned log and its bounded recent
|
|
8
13
|
window. Native `agentId` remains authoritative when it differs from the filename;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { SessionInfo } from "../../../../packages/protocol/schema";
|
|
2
|
+
import { localTaskTransfers, localTransferContinuations } from "../task-transfer";
|
|
3
|
+
import { loadRuntimeConfig } from "../config";
|
|
4
|
+
import { timedProviderScan, timedProviderScanAsync } from "../machine-load/mcp/scan-cost";
|
|
5
|
+
import { scanClaude, scanClaudeCooperatively } from "./scan/claude";
|
|
6
|
+
import { scanCodex, scanCodexCooperatively } from "./scan/codex";
|
|
7
|
+
import { scanCursor } from "./cursor";
|
|
8
|
+
import { scanGrok } from "./scan/grok";
|
|
9
|
+
import { MAX_FILES, MAX_HISTORY, MAX_LIVE, TOKEN_WINDOW_MS } from "./support/common";
|
|
10
|
+
import { setImmediate } from "node:timers/promises";
|
|
11
|
+
|
|
12
|
+
type ProviderScan = ReturnType<typeof scanClaude>;
|
|
13
|
+
|
|
14
|
+
function sessionKey(session: SessionInfo): string {
|
|
15
|
+
return `${session.agent}\0${session.sessionId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emptyScan(): ProviderScan {
|
|
19
|
+
return { sessions: [], tokensRecent: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Explicit provider roots isolate fixtures and diagnostics from real user logs.
|
|
24
|
+
* In normal operation (no overrides) every installed provider is scanned.
|
|
25
|
+
*/
|
|
26
|
+
function providerReaders(maxFiles: number) {
|
|
27
|
+
const enabled = loadRuntimeConfig().providerSettings;
|
|
28
|
+
const explicit = {
|
|
29
|
+
claude: Boolean(process.env.GRANTTAP_CLAUDE_PROJECTS_DIR || process.env.NODVOX_CLAUDE_PROJECTS_DIR),
|
|
30
|
+
codex: Boolean(process.env.GRANTTAP_CODEX_SESSIONS_DIR || process.env.NODVOX_CODEX_SESSIONS_DIR),
|
|
31
|
+
cursor: Boolean(process.env.GRANTTAP_CURSOR_TRANSCRIPTS_DIR || process.env.GRANTTAP_CURSOR_STATE_DB),
|
|
32
|
+
grok: Boolean(process.env.GRANTTAP_GROK_SESSIONS_DIR || process.env.GROK_HOME),
|
|
33
|
+
};
|
|
34
|
+
const isolated = Object.values(explicit).some(Boolean);
|
|
35
|
+
const cooperative = { claude: scanClaudeCooperatively, codex: scanCodexCooperatively,
|
|
36
|
+
cursor: async (limit: number) => scanCursor(limit), grok: async (limit: number) => scanGrok(limit) };
|
|
37
|
+
return Object.entries({ claude: scanClaude, codex: scanCodex, cursor: scanCursor, grok: scanGrok })
|
|
38
|
+
.map(([agent, read]) => {
|
|
39
|
+
const key = agent as keyof typeof explicit;
|
|
40
|
+
return { agent, read: () => read(maxFiles), cooperative: () => cooperative[key](maxFiles),
|
|
41
|
+
enabled: enabled[key] && (!isolated || explicit[key]) };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function providerScans(maxFiles = MAX_FILES): ProviderScan[] {
|
|
46
|
+
return providerReaders(maxFiles).map(reader => reader.enabled
|
|
47
|
+
? timedProviderScan(reader.agent, reader.read) : emptyScan());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function cooperativeProviderScans(maxFiles = MAX_FILES): Promise<ProviderScan[]> {
|
|
51
|
+
const scans: ProviderScan[] = [];
|
|
52
|
+
for (const reader of providerReaders(maxFiles)) {
|
|
53
|
+
await setImmediate();
|
|
54
|
+
scans.push(reader.enabled ? await timedProviderScanAsync(reader.agent, reader.cooperative) : emptyScan());
|
|
55
|
+
}
|
|
56
|
+
return scans;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sortedUniqueSessions(scans: ProviderScan[]): SessionInfo[] {
|
|
60
|
+
const merged = new Map<string, SessionInfo>();
|
|
61
|
+
for (const session of scans.flatMap((scan) => scan.sessions)) {
|
|
62
|
+
const key = sessionKey(session);
|
|
63
|
+
const previous = merged.get(key);
|
|
64
|
+
if (!previous || session.lastActivityAt >= previous.lastActivityAt) {
|
|
65
|
+
merged.set(key, session);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const rank = { working: 0, waiting: 1, idle: 2 } as const;
|
|
69
|
+
return [...merged.values()].sort((a, b) =>
|
|
70
|
+
rank[a.state] - rank[b.state] || b.lastActivityAt - a.lastActivityAt
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Preserve provider diversity when one agent has hundreds of recent logs. */
|
|
75
|
+
function pickWithReserve(
|
|
76
|
+
candidates: SessionInfo[],
|
|
77
|
+
limit: number,
|
|
78
|
+
reservePerAgent: number,
|
|
79
|
+
): SessionInfo[] {
|
|
80
|
+
if (candidates.length <= limit) return candidates;
|
|
81
|
+
const picked = new Map<string, SessionInfo>();
|
|
82
|
+
const agents = [...new Set(candidates.map((session) => session.agent))];
|
|
83
|
+
for (const agent of agents) {
|
|
84
|
+
for (const session of candidates.filter((item) => item.agent === agent).slice(0, reservePerAgent)) {
|
|
85
|
+
if (picked.size >= limit) break;
|
|
86
|
+
picked.set(sessionKey(session), session);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const session of candidates) {
|
|
90
|
+
if (picked.size >= limit) break;
|
|
91
|
+
picked.set(sessionKey(session), session);
|
|
92
|
+
}
|
|
93
|
+
const order = new Map(candidates.map((session, index) => [sessionKey(session), index]));
|
|
94
|
+
return [...picked.values()].sort(
|
|
95
|
+
(a, b) => (order.get(sessionKey(a)) ?? 0) - (order.get(sessionKey(b)) ?? 0),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function catalogFromScans(scans: ProviderScan[]) {
|
|
100
|
+
return {
|
|
101
|
+
all: localTaskTransfers().catalog(sortedUniqueSessions(scans)).map(session => ({ ...session,
|
|
102
|
+
transfer: localTransferContinuations().notice(session.sessionId) })),
|
|
103
|
+
tokensRecent: scans.reduce((sum, scan) => sum + scan.tokensRecent, 0),
|
|
104
|
+
sourceLimited: scans.some((scan) => scan.sourceLimited === true),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scanCatalog(maxFiles = MAX_FILES) {
|
|
109
|
+
return catalogFromScans(providerScans(maxFiles));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function scanSessionsCooperatively() {
|
|
113
|
+
return liveCatalog(catalogFromScans(await cooperativeProviderScans()));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function scanSessionHistoryCooperatively(): Promise<SessionInfo[]> {
|
|
117
|
+
return historyCatalog(catalogFromScans(await cooperativeProviderScans()).all);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function scanSessions(): { sessions: SessionInfo[]; tokensRecent: number } {
|
|
121
|
+
return liveCatalog(scanCatalog());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function liveCatalog({ all, tokensRecent }: { all: SessionInfo[]; tokensRecent: number }) {
|
|
125
|
+
const live = all.filter((session) =>
|
|
126
|
+
session.state !== "idle" || Date.now() - session.lastActivityAt <= TOKEN_WINDOW_MS
|
|
127
|
+
);
|
|
128
|
+
return {
|
|
129
|
+
sessions: pickWithReserve(live, MAX_LIVE, 10),
|
|
130
|
+
tokensRecent,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Bounded local history. Nested provider threads stay metadata on their parent. */
|
|
135
|
+
export function scanSessionHistory(): SessionInfo[] {
|
|
136
|
+
return historyCatalog(scanCatalog().all);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function historyCatalog(sessions: SessionInfo[]): SessionInfo[] {
|
|
140
|
+
return pickWithReserve(sessions, Math.min(MAX_HISTORY, 200), 40);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The UI asks for older catalog rows only when its History list reaches the end. */
|
|
144
|
+
export function scanSessionHistoryForPaging(maxFiles = MAX_FILES): { sessions: SessionInfo[]; sourceLimited: boolean } {
|
|
145
|
+
const scan = scanCatalog(maxFiles);
|
|
146
|
+
return {
|
|
147
|
+
sessions: scan.all.sort((left, right) =>
|
|
148
|
+
right.lastActivityAt - left.lastActivityAt
|
|
149
|
+
|| left.agent.localeCompare(right.agent)
|
|
150
|
+
|| left.sessionId.localeCompare(right.sessionId)
|
|
151
|
+
),
|
|
152
|
+
sourceLimited: scan.sourceLimited,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setImmediate } from "node:timers/promises";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
|
|
4
|
+
/** Shared provider readers preserve the same result for CLI and live transport callers. */
|
|
5
|
+
export function finishScan<T>(scan: Generator<void, T>): T {
|
|
6
|
+
let step = scan.next();
|
|
7
|
+
while (!step.done) step = scan.next();
|
|
8
|
+
return step.value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Let relay input, pongs, decisions and timers run between native files. */
|
|
12
|
+
export async function finishScanCooperatively<T>(scan: Generator<void, T>): Promise<T> {
|
|
13
|
+
for (;;) {
|
|
14
|
+
await setImmediate();
|
|
15
|
+
const deadline = performance.now() + 8;
|
|
16
|
+
do {
|
|
17
|
+
const step = scan.next();
|
|
18
|
+
if (step.done) return step.value;
|
|
19
|
+
} while (performance.now() < deadline);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* observations beside their session summary. Activity and telemetry therefore
|
|
6
6
|
* do not trigger a second filesystem walk during the monitor publish cycle.
|
|
7
7
|
*/
|
|
8
|
-
import { localTaskTransfers, localTransferContinuations } from "../task-transfer";
|
|
9
8
|
import { recentProjectDecisions } from "../policy/decision-log";
|
|
10
9
|
import type {
|
|
11
10
|
ActivityEntry,
|
|
@@ -19,25 +18,18 @@ import {
|
|
|
19
18
|
MAX_ACTIVITY_TEXT,
|
|
20
19
|
} from "./support/activity-helpers";
|
|
21
20
|
import {
|
|
22
|
-
MAX_HISTORY,
|
|
23
|
-
MAX_FILES,
|
|
24
|
-
MAX_LIVE,
|
|
25
21
|
TOKEN_WINDOW_HOURS,
|
|
26
|
-
TOKEN_WINDOW_MS,
|
|
27
22
|
} from "./support/common";
|
|
28
23
|
import {
|
|
29
24
|
claudeCapabilityUsage,
|
|
30
|
-
scanClaude,
|
|
31
25
|
} from "./scan/claude";
|
|
32
26
|
import {
|
|
33
27
|
codexCapabilityUsage,
|
|
34
|
-
scanCodex,
|
|
35
28
|
} from "./scan/codex";
|
|
36
29
|
import {
|
|
37
30
|
cursorCapabilityUsage,
|
|
38
|
-
scanCursor,
|
|
39
31
|
} from "./cursor";
|
|
40
|
-
import { grokCapabilityUsage
|
|
32
|
+
import { grokCapabilityUsage } from "./scan/grok";
|
|
41
33
|
import { activityForSession } from './session-activity';
|
|
42
34
|
export { MAX_THREAD_ENTRIES, scanThreadActivity } from './session-activity';
|
|
43
35
|
import {
|
|
@@ -46,125 +38,13 @@ import {
|
|
|
46
38
|
toRemoteCapabilityUsageEvent,
|
|
47
39
|
} from "./telemetry";
|
|
48
40
|
import { createCapabilityTotals } from "./support/capability-totals";
|
|
49
|
-
import { loadRuntimeConfig } from "../config";
|
|
50
|
-
import { timedProviderScan } from "../machine-load/mcp/scan-cost";
|
|
51
41
|
|
|
52
42
|
export { MAX_ACTIVITY_ENTRIES, MAX_ACTIVITY_TEXT, TOKEN_WINDOW_HOURS };
|
|
53
43
|
export { nativeTranscriptRoots, nativeTranscriptSource, restoreNativeTranscriptSource } from "./native-source";
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return `${session.agent}\0${session.sessionId}`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function emptyScan(): ProviderScan {
|
|
62
|
-
return { sessions: [], tokensRecent: 0 };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Explicit provider roots isolate fixtures and diagnostics from real user logs.
|
|
67
|
-
* In normal operation (no overrides) every installed provider is scanned.
|
|
68
|
-
*/
|
|
69
|
-
function providerScans(maxFiles = MAX_FILES): ProviderScan[] {
|
|
70
|
-
const enabled = loadRuntimeConfig().providerSettings;
|
|
71
|
-
const explicit = {
|
|
72
|
-
claude: Boolean(process.env.GRANTTAP_CLAUDE_PROJECTS_DIR || process.env.NODVOX_CLAUDE_PROJECTS_DIR),
|
|
73
|
-
codex: Boolean(process.env.GRANTTAP_CODEX_SESSIONS_DIR || process.env.NODVOX_CODEX_SESSIONS_DIR),
|
|
74
|
-
cursor: Boolean(process.env.GRANTTAP_CURSOR_TRANSCRIPTS_DIR || process.env.GRANTTAP_CURSOR_STATE_DB),
|
|
75
|
-
grok: Boolean(process.env.GRANTTAP_GROK_SESSIONS_DIR || process.env.GROK_HOME),
|
|
76
|
-
};
|
|
77
|
-
const isolated = Object.values(explicit).some(Boolean);
|
|
78
|
-
return [
|
|
79
|
-
enabled.claude && (!isolated || explicit.claude)
|
|
80
|
-
? timedProviderScan("claude", () => scanClaude(maxFiles)) : emptyScan(),
|
|
81
|
-
enabled.codex && (!isolated || explicit.codex)
|
|
82
|
-
? timedProviderScan("codex", () => scanCodex(maxFiles)) : emptyScan(),
|
|
83
|
-
enabled.cursor && (!isolated || explicit.cursor)
|
|
84
|
-
? timedProviderScan("cursor", () => scanCursor(maxFiles)) : emptyScan(),
|
|
85
|
-
enabled.grok && (!isolated || explicit.grok)
|
|
86
|
-
? timedProviderScan("grok", () => scanGrok(maxFiles)) : emptyScan(),
|
|
87
|
-
];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function sortedUniqueSessions(scans: ProviderScan[]): SessionInfo[] {
|
|
91
|
-
const merged = new Map<string, SessionInfo>();
|
|
92
|
-
for (const session of scans.flatMap((scan) => scan.sessions)) {
|
|
93
|
-
const key = sessionKey(session);
|
|
94
|
-
const previous = merged.get(key);
|
|
95
|
-
if (!previous || session.lastActivityAt >= previous.lastActivityAt) {
|
|
96
|
-
merged.set(key, session);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const rank = { working: 0, waiting: 1, idle: 2 } as const;
|
|
100
|
-
return [...merged.values()].sort((a, b) =>
|
|
101
|
-
rank[a.state] - rank[b.state] || b.lastActivityAt - a.lastActivityAt
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Preserve provider diversity when one agent has hundreds of recent logs. */
|
|
106
|
-
function pickWithReserve(
|
|
107
|
-
candidates: SessionInfo[],
|
|
108
|
-
limit: number,
|
|
109
|
-
reservePerAgent: number,
|
|
110
|
-
): SessionInfo[] {
|
|
111
|
-
if (candidates.length <= limit) return candidates;
|
|
112
|
-
const picked = new Map<string, SessionInfo>();
|
|
113
|
-
const agents = [...new Set(candidates.map((session) => session.agent))];
|
|
114
|
-
for (const agent of agents) {
|
|
115
|
-
for (const session of candidates.filter((item) => item.agent === agent).slice(0, reservePerAgent)) {
|
|
116
|
-
if (picked.size >= limit) break;
|
|
117
|
-
picked.set(sessionKey(session), session);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
for (const session of candidates) {
|
|
121
|
-
if (picked.size >= limit) break;
|
|
122
|
-
picked.set(sessionKey(session), session);
|
|
123
|
-
}
|
|
124
|
-
const order = new Map(candidates.map((session, index) => [sessionKey(session), index]));
|
|
125
|
-
return [...picked.values()].sort(
|
|
126
|
-
(a, b) => (order.get(sessionKey(a)) ?? 0) - (order.get(sessionKey(b)) ?? 0),
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function scanCatalog(maxFiles = MAX_FILES): { all: SessionInfo[]; tokensRecent: number; sourceLimited: boolean } {
|
|
131
|
-
const scans = providerScans(maxFiles);
|
|
132
|
-
return {
|
|
133
|
-
all: localTaskTransfers().catalog(sortedUniqueSessions(scans)).map(session => ({ ...session,
|
|
134
|
-
transfer: localTransferContinuations().notice(session.sessionId) })),
|
|
135
|
-
tokensRecent: scans.reduce((sum, scan) => sum + scan.tokensRecent, 0),
|
|
136
|
-
sourceLimited: scans.some((scan) => scan.sourceLimited === true),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function scanSessions(): { sessions: SessionInfo[]; tokensRecent: number } {
|
|
141
|
-
const { all, tokensRecent } = scanCatalog();
|
|
142
|
-
const live = all.filter((session) =>
|
|
143
|
-
session.state !== "idle" || Date.now() - session.lastActivityAt <= TOKEN_WINDOW_MS
|
|
144
|
-
);
|
|
145
|
-
return {
|
|
146
|
-
sessions: pickWithReserve(live, MAX_LIVE, 10),
|
|
147
|
-
tokensRecent,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Bounded local history. Nested provider threads stay metadata on their parent. */
|
|
152
|
-
export function scanSessionHistory(): SessionInfo[] {
|
|
153
|
-
return pickWithReserve(scanCatalog().all, Math.min(MAX_HISTORY, 200), 40);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** The UI asks for older catalog rows only when its History list reaches the end. */
|
|
157
|
-
export function scanSessionHistoryForPaging(maxFiles = MAX_FILES): { sessions: SessionInfo[]; sourceLimited: boolean } {
|
|
158
|
-
const scan = scanCatalog(maxFiles);
|
|
159
|
-
return {
|
|
160
|
-
sessions: scan.all.sort((left, right) =>
|
|
161
|
-
right.lastActivityAt - left.lastActivityAt
|
|
162
|
-
|| left.agent.localeCompare(right.agent)
|
|
163
|
-
|| left.sessionId.localeCompare(right.sessionId)
|
|
164
|
-
),
|
|
165
|
-
sourceLimited: scan.sourceLimited,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
45
|
+
export { scanSessions, scanSessionHistory, scanSessionHistoryForPaging,
|
|
46
|
+
scanSessionsCooperatively, scanSessionHistoryCooperatively } from "./catalog";
|
|
47
|
+
import { scanSessions, scanSessionHistory } from "./catalog";
|
|
168
48
|
|
|
169
49
|
/**
|
|
170
50
|
* Keep the person's chat first. Newest child rows used to fill the whole
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { finishScan, finishScanCooperatively } from "../../cooperative-scan";
|
|
2
|
+
import { withCheckoutObservationCache } from "../../support/repository-checkout";
|
|
1
3
|
import {
|
|
2
4
|
claudeChildLogPathsBySession,
|
|
3
5
|
claudeLogPathBySession,
|
|
@@ -8,6 +10,14 @@ import { recentLogs, claudeProjectsRoot, MAX_FILES, TOKEN_WINDOW_MS, type Scan }
|
|
|
8
10
|
import type { SessionInfo } from "../../../../../../packages/protocol/schema";
|
|
9
11
|
|
|
10
12
|
export function scanClaude(maxFiles = MAX_FILES): Scan {
|
|
13
|
+
return withCheckoutObservationCache(() => finishScan(scanClaudeCatalog(maxFiles)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scanClaudeCooperatively(maxFiles = MAX_FILES): Promise<Scan> {
|
|
17
|
+
return withCheckoutObservationCache(() => finishScanCooperatively(scanClaudeCatalog(maxFiles)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function* scanClaudeCatalog(maxFiles: number): Generator<void, Scan> {
|
|
11
21
|
const root = claudeProjectsRoot();
|
|
12
22
|
const sessions: SessionInfo[] = [];
|
|
13
23
|
let tokensRecent = 0;
|
|
@@ -15,15 +25,14 @@ export function scanClaude(maxFiles = MAX_FILES): Scan {
|
|
|
15
25
|
const files = listed.slice(0, maxFiles);
|
|
16
26
|
const activeFiles = new Set(files);
|
|
17
27
|
const seenSessionIds = new Set<string>();
|
|
18
|
-
|
|
19
|
-
claudeChildLogPathsBySession.clear();
|
|
28
|
+
const sources: Array<{ sessionId: string; file: string; childPaths: string[] }> = [];
|
|
20
29
|
|
|
21
30
|
for (const file of files) {
|
|
31
|
+
yield;
|
|
22
32
|
const parsed = parseClaudeFile(file);
|
|
23
33
|
if (!parsed || seenSessionIds.has(parsed.session.sessionId)) continue;
|
|
24
34
|
seenSessionIds.add(parsed.session.sessionId);
|
|
25
|
-
|
|
26
|
-
claudeChildLogPathsBySession.set(parsed.session.sessionId, parsed.childPaths);
|
|
35
|
+
sources.push({ sessionId: parsed.session.sessionId, file, childPaths: parsed.childPaths });
|
|
27
36
|
if (Date.now() - parsed.session.lastActivityAt <= TOKEN_WINDOW_MS) {
|
|
28
37
|
tokensRecent += parsed.tokensSession;
|
|
29
38
|
}
|
|
@@ -32,5 +41,11 @@ export function scanClaude(maxFiles = MAX_FILES): Scan {
|
|
|
32
41
|
for (const file of claudeSummaryCache.keys()) {
|
|
33
42
|
if (!activeFiles.has(file)) claudeSummaryCache.delete(file);
|
|
34
43
|
}
|
|
44
|
+
claudeLogPathBySession.clear();
|
|
45
|
+
claudeChildLogPathsBySession.clear();
|
|
46
|
+
for (const source of sources) {
|
|
47
|
+
claudeLogPathBySession.set(source.sessionId, source.file);
|
|
48
|
+
claudeChildLogPathsBySession.set(source.sessionId, source.childPaths);
|
|
49
|
+
}
|
|
35
50
|
return { sessions, tokensRecent, sourceLimited: listed.length > maxFiles };
|
|
36
51
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finishScan, finishScanCooperatively } from "../../cooperative-scan";
|
|
1
2
|
import {
|
|
2
3
|
codexActivitySourcesBySession,
|
|
3
4
|
codexAggregatedObservationsBySession,
|
|
@@ -11,21 +12,22 @@ import { recentLogs, codexSessionsRoot, MAX_FILES, type Scan } from "../../suppo
|
|
|
11
12
|
import { withCheckoutObservationCache } from "../../support/repository-checkout";
|
|
12
13
|
|
|
13
14
|
export function scanCodex(maxFiles = MAX_FILES): Scan {
|
|
14
|
-
return withCheckoutObservationCache(() => scanCodexCatalog(maxFiles));
|
|
15
|
+
return withCheckoutObservationCache(() => finishScan(scanCodexCatalog(maxFiles)));
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function
|
|
18
|
+
export function scanCodexCooperatively(maxFiles = MAX_FILES): Promise<Scan> {
|
|
19
|
+
return withCheckoutObservationCache(() => finishScanCooperatively(scanCodexCatalog(maxFiles)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function* scanCodexCatalog(maxFiles: number): Generator<void, Scan> {
|
|
18
23
|
const root = codexSessionsRoot();
|
|
19
24
|
const listed = recentLogs(root, 5, maxFiles + 1);
|
|
20
25
|
const files = listed.slice(0, maxFiles);
|
|
21
26
|
const activeFiles = new Set(files);
|
|
22
27
|
const seenSessionIds = new Set<string>();
|
|
23
28
|
const candidates: CodexCandidate[] = [];
|
|
24
|
-
codexLogPathBySession.clear();
|
|
25
|
-
codexActivitySourcesBySession.clear();
|
|
26
|
-
codexAggregatedObservationsBySession.clear();
|
|
27
|
-
|
|
28
29
|
for (const file of files) {
|
|
30
|
+
yield;
|
|
29
31
|
const candidate = parseCodexFile(file);
|
|
30
32
|
if (!candidate || seenSessionIds.has(candidate.session.sessionId)) continue;
|
|
31
33
|
seenSessionIds.add(candidate.session.sessionId);
|
|
@@ -34,5 +36,8 @@ function scanCodexCatalog(maxFiles: number): Scan {
|
|
|
34
36
|
for (const file of codexSummaryCache.keys()) {
|
|
35
37
|
if (!activeFiles.has(file)) codexSummaryCache.delete(file);
|
|
36
38
|
}
|
|
39
|
+
codexLogPathBySession.clear();
|
|
40
|
+
codexActivitySourcesBySession.clear();
|
|
41
|
+
codexAggregatedObservationsBySession.clear();
|
|
37
42
|
return { ...assembleCodexScan(candidates), sourceLimited: listed.length > maxFiles };
|
|
38
43
|
}
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
4
|
import { isAbsolute } from "node:path";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const checkoutScans = new AsyncLocalStorage<Map<string, string | undefined>>();
|
|
6
7
|
|
|
7
|
-
/** Share verified lookups only during
|
|
8
|
+
/** Share verified lookups only during one catalog pass, including cooperative asynchronous readers. */
|
|
8
9
|
export function withCheckoutObservationCache<T>(scan: () => T): T {
|
|
9
|
-
|
|
10
|
-
scanRoots = new Map();
|
|
11
|
-
try { return scan(); }
|
|
12
|
-
finally { scanRoots = undefined; }
|
|
10
|
+
return checkoutScans.getStore() ? scan() : checkoutScans.run(new Map(), scan);
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
function gitRoot(path: string): string | undefined {
|
|
14
|
+
const scanRoots = checkoutScans.getStore();
|
|
16
15
|
if (scanRoots?.has(path)) return scanRoots.get(path);
|
|
17
16
|
let root: string | undefined;
|
|
18
17
|
try {
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.86
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.86\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.86";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
|
|
2
2
|
import WebSocket from "ws";
|
|
3
|
-
import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
3
|
+
import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, RelayConnectionNotifications, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
4
4
|
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
5
|
-
import { openRelayMessage } from "./relay-inbound";
|
|
5
|
+
import { openRelayMessage, parseRelayControl } from "./relay-inbound";
|
|
6
6
|
import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
|
|
7
7
|
import { Payload, type Role } from "../protocol/schema";
|
|
8
8
|
import { peerPublicKeys } from "./crypto";
|
|
@@ -19,6 +19,7 @@ export class RelayClient {
|
|
|
19
19
|
private recipientRouting = false;
|
|
20
20
|
private frames?: OutboundFrameQueue;
|
|
21
21
|
private listeners = new Set<Listener>();
|
|
22
|
+
private readonly connections = new RelayConnectionNotifications();
|
|
22
23
|
private taskDispatchListeners = new Set<TaskDispatchListener>();
|
|
23
24
|
private taskOfferListeners = new Set<(control: unknown) => void | Promise<void>>();
|
|
24
25
|
private intentionalClose = false;
|
|
@@ -97,6 +98,7 @@ export class RelayClient {
|
|
|
97
98
|
)
|
|
98
99
|
.catch(() => {});
|
|
99
100
|
resolve();
|
|
101
|
+
this.connections.connected();
|
|
100
102
|
});
|
|
101
103
|
ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
|
|
102
104
|
ws.on('unexpected-response', (_request, response) => {
|
|
@@ -125,7 +127,7 @@ export class RelayClient {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
private async onRaw(raw: string): Promise<void> {
|
|
128
|
-
const control =
|
|
130
|
+
const control = parseRelayControl(raw) as { type?: string; recipientRouting?: boolean } | null;
|
|
129
131
|
if (control?.type === "relay.capabilities" && control.recipientRouting === true) {
|
|
130
132
|
this.recipientRouting = true;
|
|
131
133
|
return;
|
|
@@ -239,6 +241,11 @@ export class RelayClient {
|
|
|
239
241
|
return () => { this.taskOfferListeners.delete(listener); };
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
/** Observe each actual socket recovery so replaceable snapshots are sent immediately. */
|
|
245
|
+
onConnected(listener: () => void | Promise<void>): () => void {
|
|
246
|
+
return this.connections.subscribe(listener);
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
onMessage(l: Listener): () => void {
|
|
243
250
|
this.listeners.add(l);
|
|
244
251
|
return () => this.listeners.delete(l);
|
|
@@ -290,11 +297,3 @@ export class RelayClient {
|
|
|
290
297
|
this.reconnectTimer.unref?.();
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
|
-
|
|
294
|
-
function safeJson(s: string): unknown {
|
|
295
|
-
try {
|
|
296
|
-
return JSON.parse(s);
|
|
297
|
-
} catch {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
@@ -4,6 +4,12 @@ import type { PeerConfig } from '../relay-client-types';
|
|
|
4
4
|
import { publicKeyIdentifier } from '../ciphertext-recipient';
|
|
5
5
|
import { openFromPeersWithIdentity, openWithTransferKey } from '../crypto';
|
|
6
6
|
|
|
7
|
+
/** Invalid control frames have no routing or delivery effect. */
|
|
8
|
+
export function parseRelayControl(raw: string): unknown {
|
|
9
|
+
try { return JSON.parse(raw); }
|
|
10
|
+
catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
/** Authenticate routing, peer identity and optional task encryption before consumers run. */
|
|
8
14
|
export function openRelayMessage(cfg: PeerConfig, raw: unknown, sessionKeys: Map<string, string>,
|
|
9
15
|
peerAllowed: (key: string) => boolean) {
|
|
@@ -16,5 +16,8 @@ transient machine hooks, so a server can refuse a cooled identity before it
|
|
|
16
16
|
receives any ciphertext. This digest grants no permissions or decryption rights.
|
|
17
17
|
|
|
18
18
|
The real WebSocket integration is in `tests/core/managed-relay-renewal.test.ts`.
|
|
19
|
+
`RelayClient.onConnected` observes each actual socket opening, including recovery.
|
|
20
|
+
Consumers can immediately replace stale snapshots; notification failures cannot
|
|
21
|
+
interrupt the transport, and unsubscribing prevents later callbacks.
|
|
19
22
|
|
|
20
23
|
Licensed under the GrantTap Commercial Source License in the repository root.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type ConnectionListener = () => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
/** Socket notifications cannot interrupt transport setup or another consumer. */
|
|
4
|
+
export class RelayConnectionNotifications {
|
|
5
|
+
private readonly listeners = new Set<ConnectionListener>();
|
|
6
|
+
|
|
7
|
+
subscribe(listener: ConnectionListener): () => void {
|
|
8
|
+
this.listeners.add(listener);
|
|
9
|
+
return () => { this.listeners.delete(listener); };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
connected(): void {
|
|
13
|
+
for (const listener of this.listeners) {
|
|
14
|
+
try { void Promise.resolve(listener()).catch(() => {}); }
|
|
15
|
+
catch { /* A consumer failure does not close a healthy socket. */ }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ManagedRelayLease } from "./managed-relay-lease";
|
|
2
2
|
export { RelaySocketHealth } from "./relay-socket-health";
|
|
3
3
|
export { RelayReconnectBackoff } from "./reconnect-backoff";
|
|
4
|
+
export { RelayConnectionNotifications } from "./connection-notifications";
|
|
4
5
|
|
|
5
6
|
export { relayUpgradeHeaders } from "./upgrade-headers";
|