granttap-mcp 0.8.84 → 0.8.85
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/monitor/support/runtime.ts +22 -12
- 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
|
+
}
|
|
@@ -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 { scanCapabilityUsage,
|
|
26
|
+
import { scanCapabilityUsage, scanSessionHistoryCooperatively, scanSessionsCooperatively, scopeCapabilityUsageToRoom, 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";
|
|
@@ -71,23 +71,23 @@ function decorateSessions(
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function monitorSnapshot(input: {
|
|
74
|
+
async function monitorSnapshot(input: {
|
|
75
75
|
includeHistory: boolean;
|
|
76
76
|
forceHistory: boolean;
|
|
77
77
|
subscriptions: Set<string>;
|
|
78
|
-
history: (force?: boolean) => SessionsStatus["sessions"]
|
|
79
|
-
}): SessionsStatus {
|
|
78
|
+
history: (force?: boolean) => Promise<SessionsStatus["sessions"]>;
|
|
79
|
+
}): Promise<SessionsStatus> {
|
|
80
80
|
const { includeHistory, forceHistory, subscriptions, history } = input;
|
|
81
81
|
sweepAttachments();
|
|
82
82
|
if (loadRuntimeConfig().meshEnabled) localMeshStore().flush();
|
|
83
|
-
const { sessions, tokensRecent } =
|
|
83
|
+
const { sessions, tokensRecent } = await scanSessionsCooperatively();
|
|
84
84
|
const runtime = loadRuntimeConfig();
|
|
85
85
|
return {
|
|
86
86
|
type: "sessions.status",
|
|
87
87
|
machine: computerDisplayName(),
|
|
88
88
|
sessions: decorateSessions(sessions, subscriptions),
|
|
89
89
|
history: includeHistory
|
|
90
|
-
? decorateSessions(boundedCatalogHistory(history(forceHistory), subscriptions), subscriptions)
|
|
90
|
+
? decorateSessions(boundedCatalogHistory(await history(forceHistory), subscriptions), subscriptions)
|
|
91
91
|
: undefined,
|
|
92
92
|
tokensRecent,
|
|
93
93
|
tokenWindowHours: TOKEN_WINDOW_HOURS,
|
|
@@ -113,21 +113,24 @@ async function publishMonitorTick(input: {
|
|
|
113
113
|
client: RelayClient;
|
|
114
114
|
leadership: ReturnType<typeof monitorLeadership>;
|
|
115
115
|
subscriptions: Set<string>;
|
|
116
|
-
history: (force?: boolean) => SessionsStatus["sessions"]
|
|
116
|
+
history: (force?: boolean) => Promise<SessionsStatus["sessions"]>;
|
|
117
117
|
clocks: { lastHistoryPublishedAt: number; lastCapabilityPublishedAt: number };
|
|
118
118
|
loadLoop: { updateStatus: (status: SessionsStatus) => void };
|
|
119
119
|
forceHistory: boolean;
|
|
120
|
+
active: () => boolean;
|
|
120
121
|
}): Promise<{ lastHistoryPublishedAt: number; lastCapabilityPublishedAt: number }> {
|
|
121
122
|
const { client, leadership, subscriptions, history, clocks, loadLoop, forceHistory } = input;
|
|
122
123
|
let { lastHistoryPublishedAt, lastCapabilityPublishedAt } = clocks;
|
|
123
124
|
if (!client.isConnected) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
|
|
124
125
|
await publishHeartbeat(client).catch(() => {});
|
|
126
|
+
if (!input.active()) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
|
|
125
127
|
if (!leadership.acquire()) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
|
|
126
128
|
await client.send(approvalsStatus(), "phone", { ttlMs: INTERVAL_MS * 3, reliable: false });
|
|
127
129
|
const initial = lastHistoryPublishedAt === 0;
|
|
128
130
|
const includeHistory = forceHistory || (!initial && Date.now() - lastHistoryPublishedAt >= HISTORY_INTERVAL_MS);
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
+
const snapshot = await monitorSnapshot({ includeHistory, forceHistory, subscriptions, history });
|
|
132
|
+
if (!input.active()) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
|
|
133
|
+
const status = decorateTaskOriginCatalog(snapshot, client.room);
|
|
131
134
|
rememberPublishedCatalog(client, status);
|
|
132
135
|
await client.send(status, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false });
|
|
133
136
|
if (initial && !resumedHeads.has(client)) {
|
|
@@ -146,7 +149,7 @@ async function publishMonitorTick(input: {
|
|
|
146
149
|
if (Date.now() - lastCapabilityPublishedAt >= 30_000) {
|
|
147
150
|
const usage = scanCapabilityUsage([
|
|
148
151
|
...status.sessions,
|
|
149
|
-
...history(false),
|
|
152
|
+
...await history(false),
|
|
150
153
|
]);
|
|
151
154
|
await client.send(scopeCapabilityUsageToRoom(usage, client.room), "phone", { ttlMs: 90_000, reliable: false });
|
|
152
155
|
lastCapabilityPublishedAt = Date.now();
|
|
@@ -167,6 +170,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
167
170
|
let historyCache: { generatedAt: number; sessions: SessionsStatus["sessions"] } | undefined;
|
|
168
171
|
let lastHistoryPublishedAt = 0;
|
|
169
172
|
let lastCapabilityPublishedAt = 0;
|
|
173
|
+
let closed = false;
|
|
170
174
|
const publishMachineLoad = createMachineLoadPublisher({
|
|
171
175
|
log: (line) => process.stderr.write(`[monitor] ${line}\n`),
|
|
172
176
|
});
|
|
@@ -185,16 +189,18 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
185
189
|
},
|
|
186
190
|
});
|
|
187
191
|
|
|
188
|
-
const history = (force = false): SessionsStatus["sessions"] => {
|
|
192
|
+
const history = async (force = false): Promise<SessionsStatus["sessions"]> => {
|
|
189
193
|
if (force || !historyCache || Date.now() - historyCache.generatedAt > 60_000) {
|
|
190
|
-
historyCache = { generatedAt: Date.now(), sessions:
|
|
194
|
+
historyCache = { generatedAt: Date.now(), sessions: await scanSessionHistoryCooperatively() };
|
|
191
195
|
}
|
|
192
196
|
return historyCache.sessions;
|
|
193
197
|
};
|
|
194
198
|
|
|
195
199
|
const publish = singleFlightPublisher(async (forceHistory: boolean): Promise<void> => {
|
|
200
|
+
if (closed) return;
|
|
196
201
|
const next = await publishMonitorTick({
|
|
197
202
|
client, leadership, subscriptions, history, loadLoop, forceHistory,
|
|
203
|
+
active: () => !closed,
|
|
198
204
|
clocks: { lastHistoryPublishedAt, lastCapabilityPublishedAt },
|
|
199
205
|
});
|
|
200
206
|
lastHistoryPublishedAt = next.lastHistoryPublishedAt;
|
|
@@ -213,6 +219,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
213
219
|
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
214
220
|
});
|
|
215
221
|
|
|
222
|
+
const offConnected = client.onConnected?.(() => publish().catch(() => {}));
|
|
223
|
+
|
|
216
224
|
const stopCatalog = startPublishLoop({
|
|
217
225
|
connected: () => client.isConnected,
|
|
218
226
|
intervalMs: INTERVAL_MS,
|
|
@@ -233,6 +241,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
233
241
|
publish: () => publish(false),
|
|
234
242
|
isLeader: () => leadership.acquire(),
|
|
235
243
|
close: () => {
|
|
244
|
+
closed = true;
|
|
245
|
+
offConnected?.();
|
|
236
246
|
stopCatalog();
|
|
237
247
|
stopCatalogHeads(client);
|
|
238
248
|
stopMonitorMeshes(client);
|
|
@@ -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.85
|
|
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.85\";\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.85";
|
|
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";
|