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.
@@ -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, scanSessionHistory, scanSessions, scopeCapabilityUsageToRoom, TOKEN_WINDOW_HOURS } from "../../sessions";
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 } = scanSessions();
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 status = decorateTaskOriginCatalog(
130
- monitorSnapshot({ includeHistory, forceHistory, subscriptions, history }), client.room);
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: scanSessionHistory() };
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 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.85
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.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.84";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.84",
3
+ "version": "0.8.85",
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";