granttap-mcp 0.8.101 → 0.8.104

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.
Files changed (35) hide show
  1. package/README.md +14 -0
  2. package/apps/bridge/src/bin/runtime/monitor.ts +8 -0
  3. package/apps/bridge/src/monitor/handlers/user-message.ts +9 -10
  4. package/apps/bridge/src/monitor/message-delivery/README.md +5 -0
  5. package/apps/bridge/src/monitor/message-delivery/index.ts +10 -3
  6. package/apps/bridge/src/monitor/support/runtime.ts +34 -22
  7. package/apps/bridge/src/runtime-updates/README.md +32 -0
  8. package/apps/bridge/src/runtime-updates/http-readiness.ts +17 -0
  9. package/apps/bridge/src/runtime-updates/index.ts +74 -0
  10. package/apps/bridge/src/runtime-updates/preferences/README.md +18 -0
  11. package/apps/bridge/src/runtime-updates/preferences/index.ts +12 -0
  12. package/apps/bridge/src/runtime-updates/preferences/storage.d.mts +4 -0
  13. package/apps/bridge/src/runtime-updates/preferences/storage.mjs +35 -0
  14. package/apps/bridge/src/runtime-updates/readiness.ts +23 -0
  15. package/apps/bridge/src/runtime-updates/registry.ts +58 -0
  16. package/apps/bridge/src/runtime-updates/selection.d.mts +10 -0
  17. package/apps/bridge/src/runtime-updates/selection.mjs +46 -0
  18. package/apps/bridge/src/runtime-updates/supervision.mjs +29 -0
  19. package/apps/bridge/src/sessions/README.md +5 -0
  20. package/apps/bridge/src/sessions/catalog.ts +7 -3
  21. package/apps/mcp/src/connection-center/README.md +7 -0
  22. package/apps/mcp/src/connection-center/bridge.js +2 -2
  23. package/apps/mcp/src/connection-center/state.ts +3 -0
  24. package/apps/mcp/src/connection-center/update-controls.ts +23 -0
  25. package/apps/mcp/src/connection-center/view.js +12 -0
  26. package/apps/mcp/src/connection-center/widget.html +1 -0
  27. package/apps/mcp/src/http/http-server.ts +6 -0
  28. package/apps/mcp/src/mcp-tools/README.md +6 -1
  29. package/apps/mcp/src/mcp-tools/connect/index.ts +4 -2
  30. package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
  31. package/bin/granttap-mcp.mjs +18 -3
  32. package/cursor-plugin/README.md +1 -1
  33. package/cursor-plugin/mcp.json +1 -1
  34. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -855,3 +855,17 @@ See [repository observations](apps/bridge/src/mesh/repository-details/README.md)
855
855
  The relay can retain recipient-encrypted catalogs, Mesh snapshots, capability usage and recent chat heads for reconnect without the computer. This bounded seven-day read cache is separate from the durable encrypted command queue. See [relay state publication](packages/core/retained-state/README.md). The relay never receives decryption keys or readable chat content.
856
856
 
857
857
  Tools & Skills matches recorded usage to the exact computer, provider and execution. Codex's native history projection supplies actual MCP invocation IDs, results and durations, including calls made through Code Mode. Claude Code uses observed tool-use, result and Skill records, aggregating MCP and skill calls before its recent-detail cache is trimmed. Execution summaries remain available when older call details fall outside the recent-event budget. Counts describe recorded evidence; installation and permission do not prove usage. Missing resource measurements remain unknown. See [native capability history](apps/bridge/src/sessions/native-capability-history/README.md).
858
+
859
+ ## Automatic runtime updates
860
+
861
+ Installed GrantTap background services check for stable MCP updates automatically.
862
+ Verified updates are staged separately and applied after active requests and pending
863
+ decisions finish. Startup failures restore the previous version. Pairing and task
864
+ policies are retained; source checkouts and running provider processes stay in place.
865
+
866
+ In the plugin's GrantTap connection card, use **Automatic updates: On / Off**.
867
+ The choice is saved for this computer and takes effect without restarting the
868
+ service. Off prevents further update checks and activation; an already running
869
+ download may finish staging, and the current runtime keeps running. Turn it on to
870
+ resume automatic checks. `GRANTTAP_DISABLE_AUTO_UPDATE=1` on the service overrides
871
+ the card and keeps the switch disabled.
@@ -1,3 +1,5 @@
1
+ import { startRuntimeUpdates } from "../../runtime-updates";
2
+ import { runtimeUpdateReadiness } from "../../runtime-updates/readiness";
1
3
  /** Persistent, terminal-free task sync for the GrantTap phone app on macOS. */
2
4
  import { join } from "node:path";
3
5
  import { RelayClient } from "../../../../../packages/core/relay-client";
@@ -25,6 +27,8 @@ let stopEndpointPublisher = () => {};
25
27
  let stopTaskDispatch = () => {};
26
28
  let stopTaskNetwork = () => {};
27
29
  let stopEngineRecovery = () => {};
30
+ let stopRuntimeUpdates = () => {};
31
+ let stopUpdateReadiness = () => {};
28
32
 
29
33
  try {
30
34
  const cfg = applyNetworkRoute(loadConfig(machineConfigPath()));
@@ -40,6 +44,8 @@ try {
40
44
  stopTaskNetwork = startTaskNetworkMonitor({ config: cfg, client,
41
45
  isLeader: monitor.isLeader, peerAllowed: controllerPeerGate(cfg), publish: monitor.publish });
42
46
  stopEndpointPublisher = startEndpointPublisher(cfg);
47
+ stopRuntimeUpdates = startRuntimeUpdates(monitor.canUpdate);
48
+ stopUpdateReadiness = runtimeUpdateReadiness(monitor.canUpdate);
43
49
  } catch (error) {
44
50
  process.stderr.write(
45
51
  `[granttap-mcp] monitor is not paired: ${error instanceof Error ? error.message : String(error)}\n`,
@@ -50,6 +56,8 @@ try {
50
56
  const stop = (): void => {
51
57
  if (stopping) return;
52
58
  stopping = true;
59
+ stopRuntimeUpdates();
60
+ stopUpdateReadiness();
53
61
  stopEngineRecovery();
54
62
  stopTaskDispatch();
55
63
  stopTaskNetwork();
@@ -121,6 +121,7 @@ export async function handleUserMessage(
121
121
  onProcessing?: () => Promise<void>,
122
122
  peerPublicKey?: string,
123
123
  native?: { messageId: string; nativeOnly: true },
124
+ onRefusal?: (error: string) => Promise<void>,
124
125
  ): Promise<"accepted" | "rejected"> {
125
126
  const say = (text: string, sessionId?: string, wake = false, kind: "status" | "response" = "response") => {
126
127
  const payload = agentEventForUserMessage(message, text, sessionId, kind);
@@ -129,6 +130,11 @@ export async function handleUserMessage(
129
130
  ? sendSessionPayload(client, payload, sessionId, "phone", options)
130
131
  : client.send(payload, "phone", options)).catch(() => {});
131
132
  };
133
+ const refuse = async (error: string, wake = false): Promise<"rejected"> => {
134
+ await onRefusal?.(error);
135
+ await say(error, message.sessionId, wake);
136
+ return "rejected";
137
+ };
132
138
  if (!message.sessionId) {
133
139
  const origin = message.taskOriginId && peerPublicKey ? {
134
140
  registry: localTaskOrigins(),
@@ -150,23 +156,16 @@ export async function handleUserMessage(
150
156
  // so it runs only for a chat newer than that catalog.
151
157
  const target = publishedSession(client, message.sessionId)
152
158
  ?? scanSessions().sessions.find((session) => session.sessionId === message.sessionId);
153
- if (!target) {
154
- await say("This task is no longer available on the computer.", message.sessionId);
155
- return "rejected";
156
- }
159
+ if (!target) return refuse("This task is no longer available on the computer.");
157
160
 
158
161
  const settings = loadRuntimeConfig().providerSettings;
159
162
  if (target.agent in settings
160
163
  && settings[target.agent as keyof typeof settings] === false) {
161
- await say("This agent is disabled in GrantTap Settings.", message.sessionId, true);
162
- return "rejected";
164
+ return refuse("This agent is disabled in GrantTap Settings.", true);
163
165
  }
164
166
 
165
167
  const routingError = deliveryRoutingError(target, message);
166
- if (routingError) {
167
- await say(routingError, target.sessionId);
168
- return "rejected";
169
- }
168
+ if (routingError) return refuse(routingError);
170
169
 
171
170
  // No "sent, waiting" line from a middleman: the delivery receipt already
172
171
  // marks the person's bubble, and the next words in the chat are the answer.
@@ -22,6 +22,11 @@ message again. Only the original authenticated controller, room, and session
22
22
  may read it. Older unscoped records are not exposed through this query.
23
23
  Receipts to a known controller use its device encryption directly, so a missed
24
24
  task-key grant cannot hide a provider rejection or processing acknowledgement.
25
+ For existing chats, refusals before provider dispatch (missing task, disabled provider, or rejected
26
+ task capability) also persist a scoped rejected receipt before the relay box is
27
+ consumed. The original controller can recover that specific refusal after a
28
+ restart or lost response. Missing attachment recovery retains its existing
29
+ transport behavior; an admission refusal is never reported as agent activity.
25
30
 
26
31
  A correlated `agentActivityAt` receipt is emitted only after provider stream output
27
32
  confirms activity. It is retained under the original controller, room and session
@@ -1,7 +1,8 @@
1
1
  import type { Payload } from "../../../../../packages/protocol/schema";
2
2
  import type { RelayClient } from "../../../../../packages/core/relay-client";
3
3
  import { abandonDelivery, beginDelivery, completeDelivery,
4
- acknowledgeProcessingDelivery, processingDeliveryAcknowledged, completedDeliveryFailure, deliveryReceiptStatus } from "../../delivery";
4
+ acknowledgeProcessingDelivery, processingDeliveryAcknowledged, completedDeliveryFailure, deliveryReceiptStatus,
5
+ rejectCompletedDelivery } from "../../delivery";
5
6
  import { sendDeliveryReceipt } from "../handlers/receipts";
6
7
  import { handleUserMessage } from "../handlers/user-message";
7
8
 
@@ -34,9 +35,15 @@ export async function handleInboundUserMessage(
34
35
  if (!payload.messageId) return;
35
36
  acknowledgeProcessingDelivery(payload.messageId);
36
37
  await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId, peerPublicKey);
37
- }, peerPublicKey, native);
38
+ }, peerPublicKey, native, async error => {
39
+ if (!payload.messageId) return;
40
+ rejectCompletedDelivery(payload.messageId, error);
41
+ await sendDeliveryReceipt(client, payload.messageId, 'rejected', error, payload.sessionId, peerPublicKey);
42
+ });
38
43
  if (payload.messageId && deliveryStarted) {
39
- if (outcome === "rejected") abandonDelivery(payload.messageId);
44
+ if (outcome === "rejected") {
45
+ if (!completedDeliveryFailure(payload.messageId)) abandonDelivery(payload.messageId);
46
+ }
40
47
  else {
41
48
  completeDelivery(payload.messageId);
42
49
  await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId, peerPublicKey);
@@ -1,3 +1,4 @@
1
+ import { runtimeWorkTracker } from "../../runtime-updates/readiness";
1
2
  import { refreshCodexHooks } from "../../codex-hook-trust";
2
3
  import { computerDisplayName } from "../../../../../packages/core/computer-name";
3
4
  import { RelayClient } from "../../../../../packages/core/relay-client";
@@ -38,6 +39,7 @@ import { publishMonitorCapabilities, stopMonitorCapabilities } from "../capabili
38
39
  export type SessionMonitor = {
39
40
  publish: () => Promise<void>;
40
41
  isLeader: () => boolean;
42
+ canUpdate: () => boolean;
41
43
  close: () => void;
42
44
  };
43
45
 
@@ -163,29 +165,13 @@ async function publishMonitorTick(input: {
163
165
  export function startSessionMonitor(client: RelayClient): SessionMonitor {
164
166
  const subscriptions = new Set<string>();
165
167
  const leadership = monitorLeadership();
166
- primeSessionKeys(client);
167
- publishedCatalog(client);
168
+ restoreMonitorState(client);
168
169
  let historyCache: { generatedAt: number; sessions: SessionsStatus["sessions"] } | undefined;
169
170
  let historyRead: Promise<SessionsStatus["sessions"]> | undefined;
170
171
  let lastHistoryPublishedAt = 0;
171
172
  let closed = false;
172
- const publishMachineLoad = createMachineLoadPublisher({
173
- log: (line) => process.stderr.write(`[monitor] ${line}\n`),
174
- });
175
- const loadLoop = startMachineLoadLoop({
176
- connected: () => client.isConnected,
177
- // Only the leader reports load. Every MCP server used to run this loop
178
- // too, each with the code of the day it started, and the phone flickered
179
- // between their answers.
180
- publish: (status, intervalMs) => (leadership.acquire()
181
- ? publishMachineLoad(client, status, intervalMs)
182
- : Promise.resolve(undefined)),
183
- onError: (error) => {
184
- process.stderr.write(
185
- `[monitor] load report failed: ${error instanceof Error ? error.message : String(error)}\n`,
186
- );
187
- },
188
- });
173
+ const work = runtimeWorkTracker(() => approvalsStatus().pending.length > 0);
174
+ const loadLoop = monitorLoadLoop(client, leadership);
189
175
 
190
176
  const history = (force = false): Promise<SessionsStatus["sessions"]> => {
191
177
  if (historyRead) return historyRead;
@@ -209,7 +195,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
209
195
  lastHistoryPublishedAt = next.lastHistoryPublishedAt;
210
196
  });
211
197
 
212
- const off = client.onMessage(async (payload: Payload, peerPublicKey) => {
198
+ const off = client.onMessage((payload: Payload, peerPublicKey) => work.run(async () => {
213
199
  if (payload.type === "hello" && payload.role === "phone") {
214
200
  confirmPendingController(client.room, peerPublicKey);
215
201
  recordControllerName(peerPublicKey, payload.deviceName);
@@ -219,8 +205,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
219
205
  await replayNativeQuestions(client, peerPublicKey);
220
206
  }
221
207
  }
222
- return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
223
- });
208
+ return await handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
209
+ }));
224
210
 
225
211
  const offConnected = client.onConnected?.(() => publish().catch(() => {}));
226
212
 
@@ -244,6 +230,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
244
230
  return {
245
231
  publish: () => publish(false),
246
232
  isLeader: () => leadership.acquire(),
233
+ canUpdate: work.idle,
247
234
  close: () => {
248
235
  closed = true;
249
236
  offConnected?.();
@@ -279,3 +266,28 @@ async function replayNativeQuestions(client: RelayClient, peerPublicKey: string)
279
266
  const known = publishedCatalog(client);
280
267
  if (known) await publishCodexQuestions(client, known.sessions, { replay: true, peerPublicKey }).catch(() => {});
281
268
  }
269
+
270
+ function restoreMonitorState(client: RelayClient): void {
271
+ primeSessionKeys(client);
272
+ publishedCatalog(client);
273
+ }
274
+
275
+ function monitorLoadLoop(client: RelayClient, leadership: ReturnType<typeof monitorLeadership>) {
276
+ const publishMachineLoad = createMachineLoadPublisher({
277
+ log: (line) => process.stderr.write(`[monitor] ${line}\n`),
278
+ });
279
+ return startMachineLoadLoop({
280
+ connected: () => client.isConnected,
281
+ // Only the leader reports load. Every MCP server used to run this loop
282
+ // too, each with the code of the day it started, and the phone flickered
283
+ // between their answers.
284
+ publish: (status, intervalMs) => (leadership.acquire()
285
+ ? publishMachineLoad(client, status, intervalMs)
286
+ : Promise.resolve(undefined)),
287
+ onError: (error) => {
288
+ process.stderr.write(
289
+ `[monitor] load report failed: ${error instanceof Error ? error.message : String(error)}\n`,
290
+ );
291
+ },
292
+ });
293
+ }
@@ -0,0 +1,32 @@
1
+ # Automatic GrantTap runtime updates
2
+
3
+ The installed background monitor checks the official `granttap-mcp` npm stable
4
+ release every four hours. New versions are installed in a separate private
5
+ version directory under `~/.granttap/runtime-updates`, with lifecycle scripts
6
+ disabled, Node engine checks enabled, and registry integrity verified. A failed
7
+ installation leaves the running version in place.
8
+
9
+ The stable launcher selects the verified version for future invocations. Owned
10
+ background services switch only after their current requests finish and no
11
+ approval is pending. Existing provider processes and active stdio MCP calls keep
12
+ running. A new service that cannot start restores the previous version; that
13
+ failed version is quarantined until a newer release appears.
14
+
15
+ Pairing, account credentials, task history, capability policy, and provider
16
+ installations are not modified by runtime updates. Offline checks retry later.
17
+ Source checkouts and test processes do not update themselves. The plugin's
18
+ connection card offers a persistent **Automatic updates: On / Off** switch for
19
+ this computer. The running monitor reads it before registry checks and activation;
20
+ the launcher checks it again before handing off an owned service. Off keeps the
21
+ current selected runtime and rollback protection. An in-progress download may
22
+ finish staging but cannot activate while off. Re-enabling is observed on the next
23
+ minute tick without restarting services. Administrators may
24
+ set `GRANTTAP_DISABLE_AUTO_UPDATE=1` on their service to retain their installed
25
+ version. Private staging consumes space; old versions are retained for rollback.
26
+
27
+ Public entry: `startRuntimeUpdates`, `runtimeUpdateReadiness`, and the launcher's
28
+ `selection.mjs` / `supervision.mjs` boundary. Tests exercise failed downloads and
29
+ verification, deferred activation, concurrent installation, selection, and startup
30
+ rollback using isolated directories and owned child processes.
31
+
32
+ License: [GrantTap Commercial Source License](../../../../LICENSE).
@@ -0,0 +1,17 @@
1
+ import type { Express } from 'express';
2
+
3
+ /** Long-lived HTTP/SSE calls defer replacement until their normal completion. */
4
+ export function trackHttpRuntimeWork(app:Express):()=>boolean {
5
+ let activeRequests=0;
6
+ app.use((_req,res,next)=>{
7
+ activeRequests++;
8
+ let finished=false;
9
+ const complete=()=>{
10
+ if(!finished) {finished=true;activeRequests--;}
11
+ };
12
+ res.once('finish',complete);
13
+ res.once('close',complete);
14
+ next();
15
+ });
16
+ return ()=>activeRequests===0;
17
+ }
@@ -0,0 +1,74 @@
1
+ import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { configDir } from '../config/runtime/paths';
5
+ import { latestRelease, trustedRelease, installRelease, verifyRelease, type Release } from './registry';
6
+ import { newerVersion, readState, runtimeVersion, saveState, validRuntime, versionRoot } from './selection.mjs';
7
+ import { automaticUpdatesAllowed } from './preferences/storage.mjs';
8
+ export { runtimeUpdateSettings, setRuntimeAutoUpdate } from './preferences';
9
+
10
+ type Options={root:string;stateDirectory:string;now?:()=>number;latest?:()=>Promise<unknown>;
11
+ idle:()=>boolean;install?:(release:Release,directory:string)=>Promise<void>;
12
+ verify?:(root:string)=>Promise<void>;activate?:(root:string)=>void};
13
+
14
+ /** Stage verified releases independently; activation waits until no request or decision is pending. */
15
+ export async function checkForRuntimeUpdate(options:Options):Promise<string|undefined> {
16
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
17
+ mkdirSync(options.stateDirectory,{recursive:true,mode:0o700});
18
+ const lock=join(options.stateDirectory,'install.lock');let fd:number;
19
+ try {fd=openSync(lock,'wx',0o600);} catch {return undefined;}
20
+ try {
21
+ const release=await (options.latest??latestRelease)();
22
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
23
+ const state=readState(options.stateDirectory);
24
+ if(!trustedRelease(release) || !newerVersion(release.version,runtimeVersion(options.root))
25
+ || state.rejectedVersion===release.version || (state.version && !newerVersion(release.version,state.version)))return;
26
+ const candidate=versionRoot(options.stateDirectory,release.version);
27
+ if(!validRuntime(candidate,options.stateDirectory,release.version)) {
28
+ await (options.install??installRelease)(release,options.stateDirectory);
29
+ }
30
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
31
+ await (options.verify??verifyRelease)(candidate);
32
+ if(!validRuntime(candidate,options.stateDirectory,release.version))throw Error('Runtime verification failed');
33
+ if(!options.idle() || !automaticUpdatesAllowed(options.stateDirectory))return;
34
+ saveState(options.stateDirectory,{root:candidate,version:release.version,
35
+ previous:state.root?{root:state.root,version:state.version}:undefined});
36
+ options.activate?.(candidate);return candidate;
37
+ } finally {closeSync(fd);unlinkSync(lock);}
38
+ }
39
+
40
+ type LoopOptions={root:string;directory:string;idle:()=>boolean;check?:typeof checkForRuntimeUpdate;
41
+ now?:()=>number;schedule?:typeof setTimeout;report?:()=>void;activate?:()=>void};
42
+
43
+ /** Check failure never stops sync; timers are owned and cancellation prevents another check. */
44
+ export function runtimeUpdateLoop(options:LoopOptions):()=>void {
45
+ let stopped=false,timer:NodeJS.Timeout|undefined,lastCheck=0;
46
+ const now=options.now??Date.now,schedule=options.schedule??setTimeout;
47
+ const tick=async()=>{
48
+ if(stopped)return;
49
+ try {
50
+ if(!automaticUpdatesAllowed(options.directory)){lastCheck=0;return;}
51
+ const lock=join(options.directory,'install.lock');
52
+ try {if(now()-statSync(lock).mtimeMs>10*60_000)unlinkSync(lock);} catch { /* no stale lock */ }
53
+ if(now()-lastCheck>=4*60*60_000) {
54
+ await (options.check??checkForRuntimeUpdate)({root:options.root,stateDirectory:options.directory,idle:options.idle,
55
+ activate:()=>options.activate?.()});
56
+ lastCheck=options.idle()?now():0;
57
+ }
58
+ } catch {options.report?.();}
59
+ finally {if(!stopped){timer=schedule(()=>void tick(),60_000);timer.unref();}}
60
+ };
61
+ timer=schedule(()=>void tick(),60_000);timer.unref();
62
+ return ()=>{stopped=true;if(timer)clearTimeout(timer);};
63
+ }
64
+
65
+ /** Only the owned installed background monitor downloads updates; source checkouts stay fixed. */
66
+ export function startRuntimeUpdates(idle:()=>boolean):()=>void {
67
+ if(process.env.GRANTTAP_DISABLE_AUTO_UPDATE==='1' || process.env.GRANTTAP_LOCAL==='1'
68
+ || process.env.NODE_TEST_CONTEXT || !process.send)return ()=>{};
69
+ const root=fileURLToPath(new URL('../../../../',import.meta.url));
70
+ if(!root.includes('node_modules'))return ()=>{};
71
+ return runtimeUpdateLoop({root,directory:join(configDir(),'runtime-updates'),idle,
72
+ activate:()=>process.send?.({type:'granttap.runtime-update-ready'},()=>{}),
73
+ report:()=>process.stderr.write('[granttap-mcp] Automatic runtime update deferred; current version retained.\n')});
74
+ }
@@ -0,0 +1,18 @@
1
+ # Runtime update preference
2
+
3
+ The public runtime-updates entry exports `runtimeUpdateSettings` and
4
+ `setRuntimeAutoUpdate`. The connection card stores the computer-wide choice in
5
+ `runtime-updates/preferences.json`, separately from version selection and pairing.
6
+ Missing settings retain the existing enabled default; unreadable settings pause
7
+ updates. A service-level `GRANTTAP_DISABLE_AUTO_UPDATE=1` takes precedence.
8
+
9
+ The monitor checks the saved preference before registry access and before
10
+ activation. Its loop observes changes without restarting. The launcher checks it
11
+ again before asking the owned service to drain or stopping that service. Turning
12
+ updates off retains the selected working version and startup rollback protection.
13
+
14
+ Integration tests in `../tests/preferences.test.ts` cover live off/on and an
15
+ in-flight update. Connection-center tests cover persistence through actual MCP
16
+ calls, pairing preservation, service overrides, and the DOM switch.
17
+
18
+ License: [GrantTap Commercial Source License](../../../../../LICENSE).
@@ -0,0 +1,12 @@
1
+ import { join } from 'node:path';
2
+ import { configDir } from '../../config';
3
+ import { automaticUpdateSettings, saveAutomaticUpdateSetting } from './storage.mjs';
4
+
5
+ export function runtimeUpdateSettings() {
6
+ return automaticUpdateSettings(join(configDir(), 'runtime-updates'));
7
+ }
8
+
9
+ /** A running monitor reads this preference before its next check and before activation. */
10
+ export function setRuntimeAutoUpdate(enabled: boolean): void {
11
+ saveAutomaticUpdateSetting(join(configDir(), 'runtime-updates'), enabled);
12
+ }
@@ -0,0 +1,4 @@
1
+ export type AutomaticUpdateSettings = { enabled: boolean; lockedByEnvironment: boolean };
2
+ export function automaticUpdateSettings(directory: string): AutomaticUpdateSettings;
3
+ export function automaticUpdatesAllowed(directory: string): boolean;
4
+ export function saveAutomaticUpdateSetting(directory: string, enabled: boolean): void;
@@ -0,0 +1,35 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /** Missing preferences preserve the installed default; unreadable settings pause updates. */
6
+ export function automaticUpdateSettings(directory) {
7
+ const lockedByEnvironment = process.env.GRANTTAP_DISABLE_AUTO_UPDATE === '1';
8
+ let enabled;
9
+ try {
10
+ const value = JSON.parse(readFileSync(join(directory, 'preferences.json'), 'utf8'));
11
+ enabled = value?.automaticUpdatesEnabled === true;
12
+ } catch (error) { enabled = error?.code === 'ENOENT'; }
13
+ return { enabled: enabled && !lockedByEnvironment, lockedByEnvironment };
14
+ }
15
+
16
+ export function automaticUpdatesAllowed(directory) {
17
+ return automaticUpdateSettings(directory).enabled;
18
+ }
19
+
20
+ /** Separate from selected versions, pairing, credentials, and capability policy. */
21
+ export function saveAutomaticUpdateSetting(directory, enabled) {
22
+ if (typeof enabled !== 'boolean') throw new TypeError('Automatic updates require a boolean setting');
23
+ if (enabled && process.env.GRANTTAP_DISABLE_AUTO_UPDATE === '1') {
24
+ throw new Error('Automatic updates are disabled by service configuration');
25
+ }
26
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
27
+ const path = join(directory, 'preferences.json');
28
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
29
+ try {
30
+ writeFileSync(temporary, JSON.stringify({ automaticUpdatesEnabled: enabled }) + '\n', { mode: 0o600 });
31
+ renameSync(temporary, path);
32
+ } finally {
33
+ try { unlinkSync(temporary); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ /** Only the launcher's private IPC channel can ask the owned service to drain for an update. */
2
+ export function runtimeUpdateReadiness(idle:()=>boolean):()=>void {
3
+ const listener=(message:unknown)=>{
4
+ if((message as {type?:string})?.type==='granttap.runtime-update-check' && idle()) {
5
+ process.send?.({type:'granttap.runtime-update-ready'},()=>{});
6
+ }
7
+ };
8
+ process.on('message',listener);
9
+ process.send?.({type:'granttap.runtime-healthy'},()=>{});
10
+ return ()=>{process.off('message',listener);};
11
+ }
12
+
13
+ /** Count async work until completion; an update cannot interrupt a partially delivered command. */
14
+ export function runtimeWorkTracker(blocked:()=>boolean) {
15
+ let count=0;
16
+ return {
17
+ idle:()=>count===0 && !blocked(),
18
+ run:async<T>(work:()=>Promise<T>):Promise<T>=>{
19
+ count++;
20
+ try {return await work();} finally {count--;}
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { stableVersion, validRuntime, versionRoot } from './selection.mjs';
5
+
6
+ export type Release = {name:string;version:string;dist:{integrity:string;tarball:string}};
7
+ const registry='https://registry.npmjs.org';
8
+ export function trustedRelease(value:unknown):value is Release {
9
+ const r=value as Release|undefined;
10
+ return r?.name==='granttap-mcp' && stableVersion(r.version)
11
+ && typeof r.dist?.integrity==='string' && /^sha512-[A-Za-z0-9+/]{86}==$/.test(r.dist.integrity)
12
+ && r.dist.tarball===`${registry}/granttap-mcp/-/granttap-mcp-${r.version}.tgz`;
13
+ }
14
+ /** Registry metadata is bounded, timed, and never changes the installed runtime by itself. */
15
+ export async function latestRelease():Promise<unknown> {
16
+ const response=await fetch(registry+'/granttap-mcp/latest',{signal:AbortSignal.timeout(10_000),redirect:'error'});
17
+ if(!response.ok || !response.body) throw Error('Runtime registry unavailable');
18
+ const reader=response.body.getReader();let size=0;const chunks:Uint8Array[]=[];
19
+ try { for(;;) {const {done,value}=await reader.read();if(done)break;
20
+ size+=value.byteLength;if(size>262_144)throw Error('Runtime metadata exceeds limit');chunks.push(value);
21
+ } } finally {await reader.cancel();}
22
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
23
+ }
24
+ export function run(command:string,args:string[],timeoutMs=180_000,env:NodeJS.ProcessEnv=process.env):Promise<void> {
25
+ return new Promise((resolve,reject)=>{
26
+ const child=spawn(command,args,{env,stdio:'ignore',windowsHide:true});let done=false;
27
+ const finish=(error?:Error)=>{if(done)return;done=true;clearTimeout(timer);error?reject(error):resolve();};
28
+ const timer=setTimeout(()=>{child.kill();finish(Error('Runtime update command timed out'));},timeoutMs);
29
+ child.once('error',()=>finish(Error('Runtime update command could not start')));
30
+ child.once('exit',code=>finish(code===0?undefined:Error('Runtime update command failed')));
31
+ });
32
+ }
33
+ function npmCli():string {
34
+ const bin=dirname(process.execPath);
35
+ for(const path of [join(bin,'npm'),join(bin,'node_modules','npm','bin','npm-cli.js'),
36
+ join(bin,'..','lib','node_modules','npm','bin','npm-cli.js')]) {
37
+ if(existsSync(path) && !path.endsWith('.cmd'))return realpathSync(path);
38
+ }
39
+ throw Error('The installed Node runtime has no npm updater');
40
+ }
41
+ /** npm verifies registry integrity and Node compatibility in a version-specific, isolated prefix. */
42
+ export async function installRelease(release:Release,directory:string,runner=run):Promise<void> {
43
+ await runner(process.execPath,[npmCli(),'install','--prefix',join(directory,'versions',release.version),
44
+ '--registry',registry,'--ignore-scripts','--engine-strict','--omit=dev','--no-audit','--no-fund',
45
+ '--save-exact',`granttap-mcp@${release.version}`]);
46
+ const lock=JSON.parse(readFileSync(join(directory,'versions',release.version,'package-lock.json'),'utf8'));
47
+ const installed=lock.packages?.['node_modules/granttap-mcp'];
48
+ if(installed?.version!==release.version || installed?.integrity!==release.dist.integrity
49
+ || installed?.resolved!==release.dist.tarball || !validRuntime(versionRoot(directory,release.version),directory,release.version)) {
50
+ throw Error('Installed runtime identity or registry integrity did not match');
51
+ }
52
+ }
53
+ export async function verifyRelease(root:string):Promise<void> {
54
+ await run(process.execPath,[join(root,'bin','granttap-mcp.mjs'),'--help'],15_000,
55
+ {...process.env,GRANTTAP_DISABLE_AUTO_UPDATE:'1'});
56
+ // Validate the launcher without starting services or reading the live pairing.
57
+ await run(process.execPath,['--check',join(root,'bin','granttap-mcp.mjs')],15_000);
58
+ }
@@ -0,0 +1,10 @@
1
+ export type RuntimeState = {root?:string;version?:string;rejectedVersion?:string;previous?:RuntimeState};
2
+ export function stableVersion(value:unknown):value is string;
3
+ export function newerVersion(candidate:string,current:string):boolean;
4
+ export function readState(directory:string):RuntimeState;
5
+ export function saveState(directory:string,state:RuntimeState):void;
6
+ export function runtimeVersion(root:string):string;
7
+ export function versionRoot(directory:string,version:string):string;
8
+ export function validRuntime(root:string|undefined,directory:string,version:string|undefined):boolean;
9
+ export function selectedRuntime(bundled:string,directory:string):string;
10
+ export function rollbackRuntime(bundled:string,directory:string,failedRoot?:string):boolean;
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ export const stableVersion = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
5
+ export function newerVersion(candidate, current) {
6
+ if (!stableVersion(candidate) || !stableVersion(current)) return false;
7
+ const a=candidate.split('.').map(Number), b=current.split('.').map(Number);
8
+ for(let i=0;i<3;i++) { if(a[i]!==b[i]) return a[i]>b[i]; }
9
+ return false;
10
+ }
11
+ export function readState(directory) {
12
+ try { return JSON.parse(readFileSync(join(directory,'active.json'),'utf8')); } catch { return {}; }
13
+ }
14
+ export function saveState(directory, state) {
15
+ mkdirSync(directory,{recursive:true,mode:0o700});
16
+ const path=join(directory,'active.json'), temporary=path+'.'+process.pid+'.tmp';
17
+ writeFileSync(temporary,JSON.stringify(state),{mode:0o600});renameSync(temporary,path);
18
+ }
19
+ export function runtimeVersion(root) {
20
+ const p=JSON.parse(readFileSync(join(root,'package.json'),'utf8'));
21
+ if(p.name!=='granttap-mcp' || !stableVersion(p.version)) throw Error('Invalid GrantTap runtime package');
22
+ return p.version;
23
+ }
24
+ export function versionRoot(directory, version) {
25
+ return join(directory,'versions',version,'node_modules','granttap-mcp');
26
+ }
27
+ export function validRuntime(root, directory, version) {
28
+ try { return stableVersion(version) && resolve(root)===resolve(versionRoot(directory,version))
29
+ && runtimeVersion(root)===version && existsSync(join(root,'bin','granttap-mcp.mjs')); } catch { return false; }
30
+ }
31
+ /** The stable launcher accepts only a verified, newer package inside its private version cache. */
32
+ export function selectedRuntime(bundled, directory) {
33
+ const state=readState(directory);
34
+ return validRuntime(state.root,directory,state.version) && newerVersion(state.version,runtimeVersion(bundled))
35
+ ? state.root : bundled;
36
+ }
37
+ /** A new background service that exits during startup restores the last working selection. */
38
+ export function rollbackRuntime(bundled, directory, failedRoot) {
39
+ const state=readState(directory);
40
+ if(!state.version || (failedRoot && state.root!==failedRoot)) return false;
41
+ const rejectedVersion=state.version, previous=state.previous;
42
+ if(previous && validRuntime(previous.root,directory,previous.version)) {
43
+ saveState(directory,{...previous,rejectedVersion});
44
+ } else saveState(directory,{rejectedVersion});
45
+ return true;
46
+ }
@@ -0,0 +1,29 @@
1
+ import { selectedRuntime, rollbackRuntime } from './selection.mjs';
2
+ import { automaticUpdatesAllowed } from './preferences/storage.mjs';
3
+
4
+ /** Monitor only the child started by this launcher; external providers are never stopped. */
5
+ export function superviseRuntime(child, options) {
6
+ const {bundled,root,directory}=options;
7
+ const started=(options.now??Date.now)();let healthy=root===bundled,stopped=false;
8
+ const read=()=>selectedRuntime(bundled,directory);
9
+ const changed=()=>read()!==root;
10
+ const stop=()=>{if(stopped)return;stopped=true;clearInterval(timer);child.off('message',onMessage);};
11
+ const onMessage=message=>{
12
+ if(message?.type==='granttap.runtime-healthy')healthy=true;
13
+ if(message?.type==='granttap.runtime-update-ready' && automaticUpdatesAllowed(directory) && changed()) {stop();child.kill('SIGTERM');}
14
+ };
15
+ const tick=()=>{
16
+ if(stopped)return;
17
+ if(!healthy && (options.now??Date.now)()-started>120_000) {
18
+ rollbackRuntime(bundled,directory,root);stop();child.kill('SIGTERM');return;
19
+ }
20
+ if(automaticUpdatesAllowed(directory) && changed() && child.connected)child.send({type:'granttap.runtime-update-check'},()=>{});
21
+ };
22
+ const timer=(options.schedule??setInterval)(tick,10_000);timer.unref?.();child.on('message',onMessage);
23
+ return {stop,onExit:(code,signal)=>{
24
+ if(!stopped && !healthy && code!==0 && !['SIGTERM','SIGINT'].includes(signal)) {
25
+ rollbackRuntime(bundled,directory,root);
26
+ }
27
+ stop();
28
+ }};
29
+ }
@@ -35,6 +35,11 @@ answers below the current request. Native completion marks the final visible
35
35
  answer. Existing entry IDs replace cached timestamps without duplicating history.
36
36
  Session continuation uses the installed Grok CLI and its existing authentication.
37
37
 
38
+ Prepared transfer chats retain their stable Task identity and are ranked again
39
+ after native-session aliasing, before the bounded live and history windows.
40
+ A transferred chat therefore keeps its native activity position instead of
41
+ being appended behind older chats and disappearing from the phone's catalog.
42
+
38
43
  ## License
39
44
 
40
45
  Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
@@ -56,9 +56,9 @@ async function cooperativeProviderScans(maxFiles = MAX_FILES): Promise<ProviderS
56
56
  return scans;
57
57
  }
58
58
 
59
- function sortedUniqueSessions(scans: ProviderScan[]): SessionInfo[] {
59
+ function sortedUniqueSessions(sessions: SessionInfo[]): SessionInfo[] {
60
60
  const merged = new Map<string, SessionInfo>();
61
- for (const session of scans.flatMap((scan) => scan.sessions)) {
61
+ for (const session of sessions) {
62
62
  const key = sessionKey(session);
63
63
  const previous = merged.get(key);
64
64
  if (!previous || session.lastActivityAt >= previous.lastActivityAt) {
@@ -97,8 +97,12 @@ function pickWithReserve(
97
97
  }
98
98
 
99
99
  function catalogFromScans(scans: ProviderScan[]) {
100
+ const native = sortedUniqueSessions(scans.flatMap((scan) => scan.sessions));
101
+ // Prepared transfers replace native ids; rank their native activity before
102
+ // the bounded live/history windows, rather than appending them after older chats.
103
+ const sessions = sortedUniqueSessions(localTaskTransfers().catalog(native));
100
104
  return {
101
- all: localTaskTransfers().catalog(sortedUniqueSessions(scans)).map(session => ({ ...session,
105
+ all: sessions.map(session => ({ ...session,
102
106
  transfer: localTransferContinuations().notice(session.sessionId) })),
103
107
  tokensRecent: scans.reduce((sum, scan) => sum + scan.tokensRecent, 0),
104
108
  sourceLimited: scans.some((scan) => scan.sourceLimited === true),
@@ -32,6 +32,13 @@ the one-time URL from structured tool output when a host omits MCP metadata.
32
32
  The separate coding-app OAuth page can also use passkey to authorize that MCP
33
33
  client. The Codex-owned plugin management page is not this card.
34
34
 
35
+ The Automatic updates switch calls `set_runtime_auto_update` with an explicit
36
+ boolean and displays the confirmed persisted result from the server. The setting
37
+ is computer-wide for the GrantTap runtime; marketplace plugin updates stay under
38
+ the coding app's control. A service environment override locks it off. Failed
39
+ saves do not optimistically change the switch. Pairing and capability policy are
40
+ stored separately and preserved.
41
+
35
42
  Tests in `tests/` exercise isolated keys, a loopback encrypted phone connection,
36
43
  expiry, non-mutating status, and actual DOM button/host-message behavior.
37
44
 
@@ -70,11 +70,11 @@ async function call(name, args = {}) {
70
70
  const result = legacyBridge
71
71
  ? await legacyCall(name, args)
72
72
  : await request("tools/call", { name, arguments: args });
73
- if (result?.isError) message("Could not complete the request. Refresh status and check the relay before retrying.");
73
+ if (result?.isError) message("Could not complete the request. Refresh status before retrying.");
74
74
  else { render(result); message("Status updated."); }
75
75
  return result;
76
76
  } catch {
77
- message("No successful response. Refresh status before retrying; pairing may already have changed.");
77
+ message("No successful response. Refresh status before retrying.");
78
78
  } finally {
79
79
  setBusy(false);
80
80
  }
@@ -7,6 +7,7 @@ import { packageVersion } from "../status/package-version";
7
7
  import { connectionRuntimeStatus } from "../mcp-tools/connect/relay";
8
8
  import { beginEnrollment, cancelEnrollment, enrollmentIsOpen } from "./enrollment";
9
9
  import { loadAccountLink } from "../../../bridge/src/account-recovery/link";
10
+ import { runtimeUpdateSettings } from "../../../bridge/src/runtime-updates";
10
11
 
11
12
  export const connectionOutput = {
12
13
  status: z.enum(["disconnected", "paired", "pairing", "expired", "connected"]),
@@ -25,6 +26,7 @@ export const connectionOutput = {
25
26
  providers: z.array(z.object({ id: z.string(), status: z.string(), detail: z.string() })),
26
27
  roomPrefix: z.string(),
27
28
  accountLinkSaved: z.boolean(),
29
+ runtimeUpdates: z.object({ enabled: z.boolean(), lockedByEnvironment: z.boolean() }),
28
30
  passkeyUrl: z.string().url().optional(),
29
31
  };
30
32
 
@@ -80,6 +82,7 @@ export class ConnectionState {
80
82
  providers: inspectProviderStatusSnapshot().providers.map(({ id, status, detail }) => ({ id, status, detail })),
81
83
  roomPrefix: config?.room ? config.room.slice(0, 8) : "",
82
84
  accountLinkSaved: loadAccountLink() !== null,
85
+ runtimeUpdates: runtimeUpdateSettings(),
83
86
  },
84
87
  _meta: { granttap: code ? { pairingUri: code.pairingUri, qrDataUrl: code.qrDataUrl } : {} },
85
88
  };
@@ -0,0 +1,23 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import { z } from 'zod';
4
+ import { setRuntimeAutoUpdate } from '../../../bridge/src/runtime-updates';
5
+ import { connectionOutput } from './state';
6
+
7
+ export function registerRuntimeUpdateControl(
8
+ server: McpServer, result: () => CallToolResult, meta: Record<string, unknown>,
9
+ ): void {
10
+ server.registerTool('set_runtime_auto_update', {
11
+ title: 'GrantTap automatic updates',
12
+ description: 'Enable or disable automatic GrantTap runtime updates on this computer when the user requests it. Saves the preference without restarting services or changing pairing, tasks, or provider policy.',
13
+ inputSchema: { enabled: z.boolean().describe('Whether this computer should automatically check for and apply stable GrantTap runtime updates') },
14
+ outputSchema: connectionOutput,
15
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
16
+ _meta: meta,
17
+ }, async ({ enabled }) => {
18
+ try { setRuntimeAutoUpdate(enabled); return result(); }
19
+ catch {
20
+ return { isError: true, content: [{ type: 'text', text: 'Could not save automatic update settings. Service configuration may disable updates. Refresh status to see the current setting.' }] };
21
+ }
22
+ });
23
+ }
@@ -7,6 +7,16 @@ function message(text) { $("message").textContent = text; }
7
7
  function setBusy(value) {
8
8
  busy = value;
9
9
  document.querySelectorAll("button").forEach(button => { button.disabled = value; });
10
+ renderUpdateSetting();
11
+ }
12
+ function renderUpdateSetting() {
13
+ const setting = current.runtimeUpdates;
14
+ $("automatic-updates").textContent = "Automatic updates: " + (setting ? setting.enabled ? "On" : "Off" : "Unknown");
15
+ $("automatic-updates").setAttribute("aria-checked", String(setting?.enabled === true));
16
+ $("automatic-updates").disabled = busy || !setting || setting.lockedByEnvironment;
17
+ $("updates-detail").textContent = setting?.lockedByEnvironment
18
+ ? "Disabled by service configuration."
19
+ : "Checks for stable GrantTap runtime updates every 4 hours. Off keeps this computer’s current version.";
10
20
  }
11
21
  function clearCode() {
12
22
  pairingUri = "";
@@ -19,6 +29,7 @@ function render(value) {
19
29
  const state = result?.structuredContent ?? result?.structured_content;
20
30
  if (!state?.status || result.isError) return;
21
31
  current = state;
32
+ renderUpdateSetting();
22
33
  clearCode();
23
34
  const meta = result._meta?.granttap ?? {};
24
35
  const candidate = state.passkeyUrl || meta.passkeyUrl;
@@ -102,6 +113,7 @@ $("connect").addEventListener("click", () => {
102
113
  call("connect");
103
114
  });
104
115
  $("refresh").addEventListener("click", () => call("connection_status"));
116
+ $("automatic-updates").addEventListener("click", () => call("set_runtime_auto_update", { enabled: !current.runtimeUpdates.enabled }));
105
117
  $("passkey").addEventListener("click", () => call("connect_with_passkey"));
106
118
  $("reconnect").addEventListener("click", () => {
107
119
  pendingMode = "reconnect";
@@ -31,6 +31,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
31
31
  <img id="qr" class="hidden" alt="GrantTap pairing QR" />
32
32
  </section>
33
33
  <p id="expiry" class="small" aria-live="polite"></p>
34
+ <section aria-label="Runtime updates"><div class="actions"><button id="automatic-updates" role="switch" aria-checked="false" aria-describedby="updates-detail" disabled>Automatic updates: Unknown</button></div><p id="updates-detail" class="small">Checking update settings…</p></section>
34
35
  <details><summary>Connection details &amp; help</summary>
35
36
  <dl><dt>Computer</dt><dd id="computer">—</dd><dt>MCP version</dt><dd id="version">—</dd>
36
37
  <dt>Relay observed by this MCP</dt><dd id="relay">Not checked</dd>
@@ -1,3 +1,6 @@
1
+ import { trackHttpRuntimeWork } from "../../../bridge/src/runtime-updates/http-readiness";
2
+ import { runtimeUpdateReadiness } from "../../../bridge/src/runtime-updates/readiness";
3
+ import { approvalsStatus } from "../../../bridge/src/approvals/state";
1
4
  /**
2
5
  * GrantTap MCP over Streamable HTTP + loopback OAuth with website consent.
3
6
  *
@@ -63,6 +66,7 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
63
66
  const issuerUrl = new URL(`http://${host === "::1" ? "[::1]" : host}:${port}`);
64
67
  const provider = new GrantTapOAuthProvider(mcpUrl.href);
65
68
  const app = createMcpExpressApp({ host });
69
+ const httpIdle = trackHttpRuntimeWork(app);
66
70
  app.use(express.urlencoded({ extended: false }));
67
71
  app.use(mcpAuthRouter({
68
72
  provider,
@@ -120,11 +124,13 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
120
124
  const stopAccountRecovery = accountOrigin
121
125
  ? startAccountRecoveryPoller(accountOrigin, refreshRecoveredPairing) : () => {};
122
126
 
127
+ const stopUpdateReadiness=runtimeUpdateReadiness(()=>httpIdle() && approvalsStatus().pending.length===0);
123
128
  return {
124
129
  host,
125
130
  port,
126
131
  mcpUrl: mcpUrl.href,
127
132
  close: async () => {
133
+ stopUpdateReadiness();
128
134
  stopAccountRecovery();
129
135
  await sessions.close();
130
136
  resetConnectWatchers();
@@ -1,7 +1,8 @@
1
1
  # MCP tools
2
2
 
3
3
  The Personal MCP surface is an exact allowlist: `connection_status`, `connect`,
4
- `reconnect`, `notify`, `ask_yes_no`, and `ask`. Context compilation belongs
4
+ `connect_with_passkey`, `reconnect`, `set_runtime_auto_update`, `notify`,
5
+ `ask_yes_no`, `ask`, and `offload_task`. Context compilation belongs
5
6
  inside Project Mesh, not as extra tools. `relay.ts` owns one shared encrypted relay connection
6
7
  and the durable question lifecycle. Machine setup, custom relay selection, and
7
8
  pairing reset remains an explicit CLI operation. The narrower `reconnect` MCP
@@ -32,3 +33,7 @@ The connection center lives in `../connection-center`. Status requests are
32
33
  read-only. Saved pairing, a connected relay socket, and recent encrypted phone
33
34
  activity are separate observations. Only explicit connect/reconnect requests
34
35
  create codes; transfer material is carried in UI metadata and a user-facing QR.
36
+
37
+ `set_runtime_auto_update` saves the user’s computer-wide runtime update choice
38
+ from the connection card. It preserves pairing and capability policy, and cannot
39
+ override a service-level disable. Status exposes the confirmed setting.
@@ -8,6 +8,7 @@ import { ConnectionState, connectionOutput } from "../../connection-center/state
8
8
  import { CONNECTION_WIDGET_URI } from "./widget";
9
9
  import { resetRelay, relay } from "./relay";
10
10
  import { startPasskeyAccountLink } from "../../connection-center/passkey";
11
+ import { registerRuntimeUpdateControl } from "../../connection-center/update-controls";
11
12
 
12
13
  const widgetMeta = {
13
14
  ui: { resourceUri: CONNECTION_WIDGET_URI, visibility: ["model", "app"] },
@@ -26,6 +27,7 @@ const changes = {
26
27
 
27
28
  export function registerConnectTool(server: McpServer): void {
28
29
  const state = new ConnectionState();
30
+ registerRuntimeUpdateControl(server, () => connectionResult(state), widgetMeta);
29
31
  let pendingCall: Promise<CallToolResult> | null = null;
30
32
  const perform = (options: { issue?: boolean; replace?: boolean; addController?: boolean } = {}) => {
31
33
  if (pendingCall) return pendingCall;
@@ -126,7 +128,7 @@ async function connect(
126
128
 
127
129
  function connectionResult(state: ConnectionState): CallToolResult {
128
130
  const snapshot = state.snapshot();
129
- const { status, computer, relay, version, relayStatus } = snapshot.structuredContent;
131
+ const { status, computer, relay, version, relayStatus, runtimeUpdates } = snapshot.structuredContent;
130
132
  const instructions = status === "pairing"
131
133
  ? "Use the GrantTap card: tap Add a device if needed, then scan the QR on the card."
132
134
  : status === "disconnected" ? "Use the GrantTap card and tap Add a device. The QR stays on the card."
@@ -135,7 +137,7 @@ function connectionResult(state: ConnectionState): CallToolResult {
135
137
  return {
136
138
  ...snapshot,
137
139
  content: [{ type: "text", text:
138
- `GrantTap: ${status}. Computer: ${computer}. MCP ${version}. Relay: ${relay || "not configured"} (${relayStatus}).\n${instructions}`,
140
+ `GrantTap: ${status}. Computer: ${computer}. MCP ${version}. Relay: ${relay || "not configured"} (${relayStatus}). Automatic runtime updates: ${runtimeUpdates.enabled ? "on" : "off"}.\n${instructions}`,
139
141
  }],
140
142
  };
141
143
  }
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
 
3
- export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v4.html";
3
+ export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v5.html";
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
 
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { selectedRuntime, runtimeVersion, rollbackRuntime } from "../apps/bridge/src/runtime-updates/selection.mjs";
6
+ import { superviseRuntime } from "../apps/bridge/src/runtime-updates/supervision.mjs";
4
7
  import { createRequire } from "node:module";
5
8
  import { delimiter, dirname, join } from "node:path";
6
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -12,7 +15,12 @@ if (!isSupportedNode(process.versions.node)) {
12
15
  process.exit(1);
13
16
  }
14
17
 
15
- const root = join(dirname(fileURLToPath(import.meta.url)), "..");
18
+ const bundledRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const updateDirectory = join(process.env.GRANTTAP_CONFIG_DIR ?? process.env.NODVOX_CONFIG_DIR
20
+ ?? join(homedir(), ".granttap"), "runtime-updates");
21
+ const managed = bundledRoot.includes("node_modules") && process.env.GRANTTAP_LOCAL !== "1"
22
+ && process.env.GRANTTAP_DISABLE_AUTO_UPDATE !== "1" && !process.env.NODE_TEST_CONTEXT;
23
+ const root = managed ? selectedRuntime(bundledRoot, updateDirectory) : bundledRoot;
16
24
  const [command = "mcp", ...commandArgs] = process.argv.slice(2);
17
25
  const argument = commandArgs[0];
18
26
 
@@ -85,7 +93,7 @@ if (command === "mcp") {
85
93
 
86
94
  // Cursor (and LaunchAgents) often spawn with cwd=$HOME. Bare `--import tsx` then
87
95
  // resolves from home and dies with ERR_MODULE_NOT_FOUND. Pin absolute loaders + cwd.
88
- const requireFromBin = createRequire(import.meta.url);
96
+ const requireFromBin = createRequire(join(root,"package.json"));
89
97
  let preflight;
90
98
  let loader;
91
99
  try {
@@ -105,6 +113,7 @@ try {
105
113
  }
106
114
  }
107
115
  if (!preflight || !loader) {
116
+ if (root !== bundledRoot) rollbackRuntime(bundledRoot, updateDirectory, root);
108
117
  process.stderr.write(
109
118
  "[granttap-mcp] unable to resolve the installed tsx runtime — reinstall granttap-mcp\n",
110
119
  );
@@ -114,22 +123,27 @@ if (!preflight || !loader) {
114
123
  const forwardedArgs = ["connect", "status", "reset", "mesh", "setup", "restrictions", "phone"].includes(command)
115
124
  ? (command === "mesh" || command === "restrictions" ? commandArgs.slice(1) : commandArgs)
116
125
  : [];
126
+ const background = command === "internal" && ["monitor", "serve"].includes(argument);
117
127
  const child = spawn(
118
128
  process.execPath,
119
129
  ["--require", preflight, "--import", pathToFileURL(loader).href,
120
130
  "--import", pathToFileURL(join(root, "apps", "bridge", "src", "engine", "startup", "activation-entry.ts")).href,
121
131
  entry, ...forwardedArgs],
122
132
  {
123
- stdio: "inherit",
133
+ stdio: background ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
124
134
  cwd: root,
125
135
  env: {
126
136
  ...process.env,
137
+ GRANTTAP_PACKAGE_VERSION: runtimeVersion(root),
127
138
  NODE_PATH: [join(root, "node_modules"), process.env.NODE_PATH].filter(Boolean).join(delimiter),
128
139
  },
129
140
  },
130
141
  );
131
142
 
143
+ const supervision = background && managed ? superviseRuntime(child, {bundled:bundledRoot, root, directory:updateDirectory}) : undefined;
144
+
132
145
  function stopChild() {
146
+ supervision?.stop();
133
147
  try {
134
148
  if (process.platform === "win32") child.kill();
135
149
  else child.kill("SIGTERM");
@@ -148,6 +162,7 @@ child.on("error", (error) => {
148
162
  });
149
163
 
150
164
  child.on("exit", (code, signal) => {
165
+ supervision?.onExit(code, signal);
151
166
  if (signal && process.platform !== "win32") {
152
167
  try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }
153
168
  } else process.exit(code ?? 1);
@@ -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.101
13
+ npm install -g granttap-mcp@0.8.104
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.101\";\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.104\";\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.101";
10
+ const PACKAGE = "granttap-mcp@0.8.104";
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.101",
3
+ "version": "0.8.104",
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": {