granttap-mcp 0.8.51 → 0.8.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/bridge/src/monitor/support/runtime.ts +4 -4
- package/apps/bridge/src/session-catalog/README.md +2 -0
- package/apps/bridge/src/session-catalog/index.ts +1 -0
- package/apps/bridge/src/session-catalog/publish-heads.ts +27 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/outbound-scheduler/README.md +7 -0
- package/packages/core/outbound-scheduler/active-controllers.ts +15 -0
- package/packages/core/outbound-scheduler/index.ts +1 -0
- package/packages/core/relay-client.ts +9 -2
|
@@ -18,7 +18,8 @@ import { confirmPendingController } from "../../pairing/controllers";
|
|
|
18
18
|
import { recordControllerName } from "../../pairing/presence";
|
|
19
19
|
import { startPublishLoop } from "./publish-loop";
|
|
20
20
|
import { singleFlightPublisher } from "./single-flight";
|
|
21
|
-
import { publishedCatalog, rememberPublishedCatalog, resumePublishedChats
|
|
21
|
+
import { publishedCatalog, rememberPublishedCatalog, resumePublishedChats,
|
|
22
|
+
publishCatalogHeads, stopCatalogHeads } from "../../session-catalog";
|
|
22
23
|
import { createMachineLoadPublisher } from "../../machine-load";
|
|
23
24
|
import { startMachineLoadLoop } from "../../machine-load/host/loop";
|
|
24
25
|
import { scanCapabilityUsage, scanSessionHistory, scanSessions, scopeCapabilityUsageToRoom, TOKEN_WINDOW_HOURS } from "../../sessions";
|
|
@@ -135,9 +136,7 @@ async function publishMonitorTick(input: {
|
|
|
135
136
|
if (includeHistory || initial) lastHistoryPublishedAt = Date.now();
|
|
136
137
|
loadLoop.updateStatus(status);
|
|
137
138
|
void refreshMcpLoad(status.sessions).catch(() => {});
|
|
138
|
-
|
|
139
|
-
await publishSessionEvents(client, sessionId, status).catch(() => false);
|
|
140
|
-
}
|
|
139
|
+
publishCatalogHeads(client, status, subscriptions, publishSessionEvents);
|
|
141
140
|
await client.send(approvalsStatus(), "phone", { ttlMs: INTERVAL_MS * 3, reliable: false });
|
|
142
141
|
if (loadRuntimeConfig().meshEnabled) publishMonitorMeshes(client, status);
|
|
143
142
|
if (Date.now() - lastCapabilityPublishedAt >= 30_000) {
|
|
@@ -226,6 +225,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
226
225
|
publish: () => publish(false),
|
|
227
226
|
close: () => {
|
|
228
227
|
stopCatalog();
|
|
228
|
+
stopCatalogHeads(client);
|
|
229
229
|
stopMonitorMeshes(client);
|
|
230
230
|
stopHeartbeat();
|
|
231
231
|
loadLoop.stop();
|
|
@@ -19,3 +19,5 @@ Tests live in `tests/`. This runtime module uses the repository MIT License.
|
|
|
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
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.
|
|
22
|
+
|
|
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.
|
|
@@ -6,6 +6,7 @@ import { SessionsStatus, type SessionInfo } from "../../../../packages/protocol/
|
|
|
6
6
|
import { configDir, writePrivateFile } from "../config";
|
|
7
7
|
import { nativeTranscriptRoots, nativeTranscriptSource, restoreNativeTranscriptSource } from "../sessions";
|
|
8
8
|
export { recoverCatalogBindings } from "./startup-recovery";
|
|
9
|
+
export { publishCatalogHeads, stopCatalogHeads } from "./publish-heads";
|
|
9
10
|
|
|
10
11
|
const catalogs = new WeakMap<RelayClient, SessionsStatus | undefined>();
|
|
11
12
|
const cachePath = () => join(configDir(), "session-catalog-cache.json");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RelayClient } from "../../../../packages/core/relay-client";
|
|
2
|
+
import type { SessionsStatus } from "../../../../packages/protocol/schema";
|
|
3
|
+
|
|
4
|
+
type HeadRun = { running: boolean; closed: boolean };
|
|
5
|
+
const runs = new WeakMap<RelayClient, HeadRun>();
|
|
6
|
+
|
|
7
|
+
/** Transcript transfers must never occupy the fresh catalog's publication slot. */
|
|
8
|
+
export function publishCatalogHeads(client: RelayClient, status: SessionsStatus,
|
|
9
|
+
subscriptions: ReadonlySet<string>,
|
|
10
|
+
publish: (client: RelayClient, sessionId: string, status: SessionsStatus) => Promise<unknown>): void {
|
|
11
|
+
const state = runs.get(client) ?? { running: false, closed: false };
|
|
12
|
+
runs.set(client, state);
|
|
13
|
+
if (state.running || state.closed || subscriptions.size === 0) return;
|
|
14
|
+
state.running = true;
|
|
15
|
+
void (async () => {
|
|
16
|
+
for (const sessionId of [...subscriptions]) {
|
|
17
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
18
|
+
if (state.closed || !client.isConnected) break;
|
|
19
|
+
await publish(client, sessionId, status).catch(() => {});
|
|
20
|
+
}
|
|
21
|
+
})().finally(() => { state.running = false; });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stopCatalogHeads(client: RelayClient): void {
|
|
25
|
+
const state = runs.get(client);
|
|
26
|
+
if (state) state.closed = true;
|
|
27
|
+
}
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.53
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.53\";\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 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.53";
|
|
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
|
@@ -11,3 +11,10 @@ Tests live in `tests/`; they include an actual WebSocket server with the relay's
|
|
|
11
11
|
rate limit. A socket write is transport evidence, never an approval receipt.
|
|
12
12
|
|
|
13
13
|
MIT License; see the repository LICENSE.
|
|
14
|
+
# Active controllers
|
|
15
|
+
|
|
16
|
+
Authenticated incoming phone traffic marks that controller active for 90 seconds.
|
|
17
|
+
Replaceable snapshots target recent controllers while reliable messages retain
|
|
18
|
+
every authorized recipient. A transport with no recent controller falls back to
|
|
19
|
+
the complete authorized set for legacy clients and reconnection. Routing never
|
|
20
|
+
deletes a pairing or replaces the controller authorization gate.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Activity routes replaceable traffic; authorization remains a separate check. */
|
|
2
|
+
export class ActiveControllerRecipients {
|
|
3
|
+
private readonly seen = new Map<string, number>();
|
|
4
|
+
|
|
5
|
+
observe(peer: string, now = Date.now()): void {
|
|
6
|
+
this.seen.set(peer, now);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
allows(peer: string, replaceable: boolean, now = Date.now()): boolean {
|
|
10
|
+
if (!replaceable) return true;
|
|
11
|
+
const active = [...this.seen].filter(([, at]) => now >= at && now - at <= 90_000);
|
|
12
|
+
// A newly connected transport has not heard its controllers yet.
|
|
13
|
+
return active.length === 0 || active.some(([key]) => key === peer);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { PeerConfig, SendOptions } from "../relay-client-types";
|
|
|
3
3
|
import type { Payload, Role } from "../../protocol/schema";
|
|
4
4
|
import { peerPublicKeys } from "../crypto";
|
|
5
5
|
import { sealEnvelope } from "../relay-envelope";
|
|
6
|
+
export { ActiveControllerRecipients } from "./active-controllers";
|
|
6
7
|
|
|
7
8
|
type Job = { frame: string; bytes: number; resolve: () => void; reject: (error: Error) => void };
|
|
8
9
|
const CLOSED = () => new Error("relay connection closed before delivery");
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the Cloudflare Durable Object deployment.
|
|
11
11
|
*/
|
|
12
12
|
import WebSocket from "ws";
|
|
13
|
-
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
13
|
+
import { OutboundFrameQueue, sendEncryptedPayload, ActiveControllerRecipients } from "./outbound-scheduler";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { Envelope, Payload, type Role } from "../protocol/schema";
|
|
16
16
|
import { openFromPeersWithIdentity, openWithTransferKey, peerPublicKeys, seal, sealWithTransferKey } from "./crypto";
|
|
@@ -34,6 +34,7 @@ export class RelayClient {
|
|
|
34
34
|
private livenessTimer?: NodeJS.Timeout;
|
|
35
35
|
private awaitingPong = false;
|
|
36
36
|
private readonly sessionKeys = new Map<string, string>();
|
|
37
|
+
private readonly activeControllers = new ActiveControllerRecipients();
|
|
37
38
|
/**
|
|
38
39
|
* Delivery ids are relay-controlled metadata, so replay protection is bound
|
|
39
40
|
* to the authenticated ciphertext itself. Keep a bounded process-local set:
|
|
@@ -199,6 +200,9 @@ export class RelayClient {
|
|
|
199
200
|
this.sessionKeys.set(payload.sessionId, payload.key);
|
|
200
201
|
}
|
|
201
202
|
if (this.processingCiphertexts.has(fingerprint)) return;
|
|
203
|
+
if (this.cfg.role === "machine" && (payload.type !== "hello" || payload.role === "phone")) {
|
|
204
|
+
this.activeControllers.observe(peerPublicKey);
|
|
205
|
+
}
|
|
202
206
|
this.processingCiphertexts.add(fingerprint);
|
|
203
207
|
try {
|
|
204
208
|
const results = await Promise.all([...this.listeners].map(async (listener) => {
|
|
@@ -234,7 +238,10 @@ export class RelayClient {
|
|
|
234
238
|
const ws = this.ws;
|
|
235
239
|
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
|
|
236
240
|
if (!this.frames) throw new Error("relay not connected");
|
|
237
|
-
|
|
241
|
+
const replaceable = this.cfg.role === "machine" && to === "phone" && options.reliable === false;
|
|
242
|
+
await sendEncryptedPayload(this.frames, this.cfg,
|
|
243
|
+
key => this.peerIsAllowed(key) && this.activeControllers.allows(key, replaceable),
|
|
244
|
+
{ payload, to, options });
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
/** Add a newly issued controller without waiting for the monitor to restart. */
|