granttap-mcp 0.8.101 → 0.8.102

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/README.md CHANGED
@@ -855,3 +855,10 @@ 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.
@@ -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();
@@ -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,26 @@
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. Administrators may
18
+ set `GRANTTAP_DISABLE_AUTO_UPDATE=1` on their service to retain their installed
19
+ version. Private staging consumes space; old versions are retained for rollback.
20
+
21
+ Public entry: `startRuntimeUpdates`, `runtimeUpdateReadiness`, and the launcher's
22
+ `selection.mjs` / `supervision.mjs` boundary. Tests exercise failed downloads and
23
+ verification, deferred activation, concurrent installation, selection, and startup
24
+ rollback using isolated directories and owned child processes.
25
+
26
+ 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,68 @@
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
+
8
+ type Options={root:string;stateDirectory:string;now?:()=>number;latest?:()=>Promise<unknown>;
9
+ idle:()=>boolean;install?:(release:Release,directory:string)=>Promise<void>;
10
+ verify?:(root:string)=>Promise<void>;activate?:(root:string)=>void};
11
+
12
+ /** Stage verified releases independently; activation waits until no request or decision is pending. */
13
+ export async function checkForRuntimeUpdate(options:Options):Promise<string|undefined> {
14
+ mkdirSync(options.stateDirectory,{recursive:true,mode:0o700});
15
+ const lock=join(options.stateDirectory,'install.lock');let fd:number;
16
+ try {fd=openSync(lock,'wx',0o600);} catch {return undefined;}
17
+ try {
18
+ const release=await (options.latest??latestRelease)();
19
+ const state=readState(options.stateDirectory);
20
+ if(!trustedRelease(release) || !newerVersion(release.version,runtimeVersion(options.root))
21
+ || state.rejectedVersion===release.version || (state.version && !newerVersion(release.version,state.version)))return;
22
+ const candidate=versionRoot(options.stateDirectory,release.version);
23
+ if(!validRuntime(candidate,options.stateDirectory,release.version)) {
24
+ await (options.install??installRelease)(release,options.stateDirectory);
25
+ }
26
+ await (options.verify??verifyRelease)(candidate);
27
+ if(!validRuntime(candidate,options.stateDirectory,release.version))throw Error('Runtime verification failed');
28
+ if(!options.idle())return;
29
+ saveState(options.stateDirectory,{root:candidate,version:release.version,
30
+ previous:state.root?{root:state.root,version:state.version}:undefined});
31
+ options.activate?.(candidate);return candidate;
32
+ } finally {closeSync(fd);unlinkSync(lock);}
33
+ }
34
+
35
+ type LoopOptions={root:string;directory:string;idle:()=>boolean;check?:typeof checkForRuntimeUpdate;
36
+ now?:()=>number;schedule?:typeof setTimeout;report?:()=>void;activate?:()=>void};
37
+
38
+ /** Check failure never stops sync; timers are owned and cancellation prevents another check. */
39
+ export function runtimeUpdateLoop(options:LoopOptions):()=>void {
40
+ let stopped=false,timer:NodeJS.Timeout|undefined,lastCheck=0;
41
+ const now=options.now??Date.now,schedule=options.schedule??setTimeout;
42
+ const tick=async()=>{
43
+ if(stopped)return;
44
+ try {
45
+ const lock=join(options.directory,'install.lock');
46
+ try {if(now()-statSync(lock).mtimeMs>10*60_000)unlinkSync(lock);} catch { /* no stale lock */ }
47
+ if(now()-lastCheck>=4*60*60_000) {
48
+ await (options.check??checkForRuntimeUpdate)({root:options.root,stateDirectory:options.directory,idle:options.idle,
49
+ activate:()=>options.activate?.()});
50
+ lastCheck=options.idle()?now():0;
51
+ }
52
+ } catch {options.report?.();}
53
+ finally {if(!stopped){timer=schedule(()=>void tick(),60_000);timer.unref();}}
54
+ };
55
+ timer=schedule(()=>void tick(),60_000);timer.unref();
56
+ return ()=>{stopped=true;if(timer)clearTimeout(timer);};
57
+ }
58
+
59
+ /** Only the owned installed background monitor downloads updates; source checkouts stay fixed. */
60
+ export function startRuntimeUpdates(idle:()=>boolean):()=>void {
61
+ if(process.env.GRANTTAP_DISABLE_AUTO_UPDATE==='1' || process.env.GRANTTAP_LOCAL==='1'
62
+ || process.env.NODE_TEST_CONTEXT || !process.send)return ()=>{};
63
+ const root=fileURLToPath(new URL('../../../../',import.meta.url));
64
+ if(!root.includes('node_modules'))return ()=>{};
65
+ return runtimeUpdateLoop({root,directory:join(configDir(),'runtime-updates'),idle,
66
+ activate:()=>process.send?.({type:'granttap.runtime-update-ready'},()=>{}),
67
+ report:()=>process.stderr.write('[granttap-mcp] Automatic runtime update deferred; current version retained.\n')});
68
+ }
@@ -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,28 @@
1
+ import { selectedRuntime, rollbackRuntime } from './selection.mjs';
2
+
3
+ /** Monitor only the child started by this launcher; external providers are never stopped. */
4
+ export function superviseRuntime(child, options) {
5
+ const {bundled,root,directory}=options;
6
+ const started=(options.now??Date.now)();let healthy=root===bundled,stopped=false;
7
+ const read=()=>selectedRuntime(bundled,directory);
8
+ const changed=()=>read()!==root;
9
+ const stop=()=>{if(stopped)return;stopped=true;clearInterval(timer);child.off('message',onMessage);};
10
+ const onMessage=message=>{
11
+ if(message?.type==='granttap.runtime-healthy')healthy=true;
12
+ if(message?.type==='granttap.runtime-update-ready' && changed()) {stop();child.kill('SIGTERM');}
13
+ };
14
+ const tick=()=>{
15
+ if(stopped)return;
16
+ if(!healthy && (options.now??Date.now)()-started>120_000) {
17
+ rollbackRuntime(bundled,directory,root);stop();child.kill('SIGTERM');return;
18
+ }
19
+ if(changed() && child.connected)child.send({type:'granttap.runtime-update-check'},()=>{});
20
+ };
21
+ const timer=(options.schedule??setInterval)(tick,10_000);timer.unref?.();child.on('message',onMessage);
22
+ return {stop,onExit:(code,signal)=>{
23
+ if(!stopped && !healthy && code!==0 && !['SIGTERM','SIGINT'].includes(signal)) {
24
+ rollbackRuntime(bundled,directory,root);
25
+ }
26
+ stop();
27
+ }};
28
+ }
@@ -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,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.102
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.102\";\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.102";
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.102",
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": {