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.
@@ -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 { inspectRepository } from "../../catalog";
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: typeof inspectRepository;
21
+ inspect: (cwd: string) => RepositoryFacts | Promise<RepositoryFacts>;
22
22
  sync: typeof queueProjectBindingSync;
23
- } = { endpoint: computerId, inspect: inspectRepository, sync: queueProjectBindingSync },
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.flatMap((binding) => {
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: typeof inspectRepository, path: string) {
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 { scanCapabilityUsage, scanSessionHistory, scanSessions, scopeCapabilityUsageToRoom, TOKEN_WINDOW_HOURS } from "../../sessions";
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 } = scanSessions();
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; lastCapabilityPublishedAt: number };
117
+ history: (force?: boolean) => Promise<SessionsStatus["sessions"]>;
118
+ clocks: { lastHistoryPublishedAt: number };
118
119
  loadLoop: { updateStatus: (status: SessionsStatus) => void };
119
120
  forceHistory: boolean;
120
- }): Promise<{ lastHistoryPublishedAt: number; lastCapabilityPublishedAt: number }> {
121
+ active: () => boolean;
122
+ }): Promise<{ lastHistoryPublishedAt: number }> {
121
123
  const { client, leadership, subscriptions, history, clocks, loadLoop, forceHistory } = input;
122
- let { lastHistoryPublishedAt, lastCapabilityPublishedAt } = clocks;
123
- if (!client.isConnected) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
124
+ let { lastHistoryPublishedAt } = clocks;
125
+ if (!client.isConnected) return { lastHistoryPublishedAt };
124
126
  await publishHeartbeat(client).catch(() => {});
125
- if (!leadership.acquire()) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
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 status = decorateTaskOriginCatalog(
130
- monitorSnapshot({ includeHistory, forceHistory, subscriptions, history }), client.room);
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
- if (Date.now() - lastCapabilityPublishedAt >= 30_000) {
147
- const usage = scanCapabilityUsage([
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 lastCapabilityPublishedAt = 0;
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 (force || !historyCache || Date.now() - historyCache.generatedAt > 60_000) {
190
- historyCache = { generatedAt: Date.now(), sessions: scanSessionHistory() };
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
- return historyCache.sessions;
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
- clocks: { lastHistoryPublishedAt, lastCapabilityPublishedAt },
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 shares repeated checkout verification only within one synchronous 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.
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, scanGrok } from "./scan/grok";
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
- type ProviderScan = ReturnType<typeof scanClaude>;
56
-
57
- function sessionKey(session: SessionInfo): string {
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
- claudeLogPathBySession.clear();
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
- claudeLogPathBySession.set(parsed.session.sessionId, file);
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 scanCodexCatalog(maxFiles: number): Scan {
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
- let scanRoots: Map<string, string | undefined> | undefined;
6
+ const checkoutScans = new AsyncLocalStorage<Map<string, string | undefined>>();
6
7
 
7
- /** Share verified lookups only during this synchronous catalog pass. */
8
+ /** Share verified lookups only during one catalog pass, including cooperative asynchronous readers. */
8
9
  export function withCheckoutObservationCache<T>(scan: () => T): T {
9
- if (scanRoots) return scan();
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 {
@@ -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.84
13
+ npm install -g granttap-mcp@0.8.86
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -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.84\";\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"
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.84";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.84",
3
+ "version": "0.8.86",
4
4
  "description": "Commercial computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = safeJson(raw) as { type?: string; recipientRouting?: boolean } | null;
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";