granttap-mcp 0.8.107 → 0.8.110

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 (48) hide show
  1. package/apps/bridge/src/codex-desktop/README.md +5 -0
  2. package/apps/bridge/src/codex-desktop/connection.ts +3 -1
  3. package/apps/bridge/src/device-management/cache.ts +73 -0
  4. package/apps/bridge/src/device-management/index.ts +89 -0
  5. package/apps/bridge/src/device-management/login.ts +119 -0
  6. package/apps/bridge/src/device-management/maintenance.ts +91 -0
  7. package/apps/bridge/src/device-management/provider-command.ts +89 -0
  8. package/apps/bridge/src/monitor/handlers/inbound.ts +2 -0
  9. package/apps/bridge/src/monitor/support/runtime.ts +3 -1
  10. package/apps/bridge/src/providers/acp-file-changes/README.md +10 -0
  11. package/apps/bridge/src/providers/acp-file-changes/index.ts +27 -0
  12. package/apps/bridge/src/providers/grok-conversation/README.md +21 -0
  13. package/apps/bridge/src/providers/grok-conversation/index.ts +66 -0
  14. package/apps/bridge/src/providers/grok-conversation/projection.ts +120 -0
  15. package/apps/bridge/src/providers/grok-tools/README.md +22 -0
  16. package/apps/bridge/src/providers/grok-tools/changes.ts +28 -0
  17. package/apps/bridge/src/providers/grok-tools/events.ts +59 -0
  18. package/apps/bridge/src/providers/grok-tools/index.ts +55 -0
  19. package/apps/bridge/src/reply/cursor-conversation/README.md +5 -0
  20. package/apps/bridge/src/reply/cursor-conversation/index.ts +9 -2
  21. package/apps/bridge/src/reply/index.ts +8 -3
  22. package/apps/bridge/src/reply/process/auth-failure.ts +11 -0
  23. package/apps/bridge/src/reply/process/index.ts +24 -2
  24. package/apps/bridge/src/reply/process/provider-headless.ts +2 -1
  25. package/apps/bridge/src/runtime-updates/index.ts +6 -5
  26. package/apps/bridge/src/sessions/cursor/conversations/README.md +5 -0
  27. package/apps/bridge/src/sessions/cursor/conversations/history.ts +5 -2
  28. package/apps/bridge/src/sessions/cursor/conversations/index.ts +19 -3
  29. package/apps/bridge/src/sessions/cursor/conversations/tools.ts +36 -0
  30. package/apps/bridge/src/sessions/scan/grok.ts +21 -14
  31. package/apps/bridge/src/sessions/support/activity-helpers.ts +1 -0
  32. package/apps/bridge/src/task-dispatch/README.md +15 -0
  33. package/apps/bridge/src/task-dispatch/sender/index.ts +9 -4
  34. package/apps/bridge/src/task-dispatch/worker/monitor.ts +5 -0
  35. package/apps/bridge/src/task-dispatch/worker/readiness.ts +8 -1
  36. package/apps/bridge/src/task-transfer/README.md +14 -0
  37. package/apps/bridge/src/task-transfer/context.ts +11 -3
  38. package/apps/bridge/src/task-transfer/persistence.ts +3 -2
  39. package/apps/bridge/src/task-transfer/runtime/local.ts +2 -1
  40. package/apps/mcp/src/desktop/README.md +4 -0
  41. package/apps/mcp/src/desktop/task-activity-runner.ts +6 -3
  42. package/cursor-plugin/README.md +1 -1
  43. package/cursor-plugin/mcp.json +1 -1
  44. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  45. package/docs/device-management.md +49 -0
  46. package/package.json +1 -1
  47. package/packages/protocol/messages/device/management.ts +73 -0
  48. package/packages/protocol/schema.ts +4 -0
@@ -34,3 +34,8 @@ Document staging for a native-only question keeps the file note inside its
34
34
  original answer envelope. The host's native question and all paired controllers
35
35
  therefore observe the same question item as answered; document paths remain
36
36
  available to the admitted turn. Ordinary chat text is unchanged.
37
+
38
+ The bounded native admission deadline leaves time for the installed router’s
39
+ ten-second owner discovery window. A five-second client cap otherwise reports
40
+ healthy CLI conversations unavailable before discovery can finish. Missing-owner
41
+ confirmation preserves CLI continuation; uncertain dispatch remains unreplayed.
@@ -17,7 +17,9 @@ export class DesktopConnection {
17
17
  private readonly deadline: number;
18
18
 
19
19
  constructor(path: string, timeoutMs: number) {
20
- this.deadline = Date.now() + Math.min(5_000, timeoutMs);
20
+ // The native router allows ten seconds for owner discovery. Keep time
21
+ // for initialization, discovery and its routed response within the turn.
22
+ this.deadline = Date.now() + Math.min(30_000, timeoutMs);
21
23
  this.socket = connect(path);
22
24
  this.socket.on('error', () => this.close());
23
25
  this.socket.on('close', () => this.close());
@@ -0,0 +1,73 @@
1
+ import { lstat, readdir, rm, mkdir, open, unlink } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { readState, stableVersion } from '../runtime-updates/selection.mjs';
6
+ import { configDir } from '../config/runtime/paths';
7
+
8
+ type Category = { id: string; name: string; bytes: number; reclaimableBytes: number; paths: string[] };
9
+ const exec = promisify(execFile);
10
+
11
+ async function bytes(path: string): Promise<number> {
12
+ try {
13
+ const s = await lstat(path);
14
+ if (s.isSymbolicLink()) return 0;
15
+ if (!s.isDirectory()) return s.isFile() ? s.size : 0;
16
+ let total = 0;
17
+ for (const name of await readdir(path)) total += await bytes(join(path, name));
18
+ return total;
19
+ } catch { return 0; }
20
+ }
21
+
22
+ /** Only disposable indexes and obsolete immutable runtime packages. Never chats, credentials or projects. */
23
+ export async function computerCache(root: string, directory = configDir(), processes?: string): Promise<Category[]> {
24
+ const graph = join(directory, 'repository-graph-cache');
25
+ const graphBytes = await bytes(graph);
26
+ const categories: Category[] = [{ id: 'graph', name: 'Project indexes', bytes: graphBytes, reclaimableBytes: graphBytes, paths: [graph] }];
27
+ const runtimeDirectory = join(directory, 'runtime-updates');
28
+ const state = readState(runtimeDirectory);
29
+ const protectedRoots = [root, state.root, state.previous?.root].filter((v): v is string => typeof v === 'string').map(v => resolve(v));
30
+ // If process inspection is unavailable, retain every runtime rather than guess.
31
+ if (processes == null) {
32
+ try { processes = (await exec('/bin/ps', ['-axo', 'command='], { maxBuffer: 8 * 1024 * 1024, timeout: 3_000 })).stdout; }
33
+ catch { processes = undefined; }
34
+ }
35
+ const runtime: Category = { id: 'runtimes', name: 'Previous MCP downloads', bytes: 0, reclaimableBytes: 0, paths: [] };
36
+ const versions = join(runtimeDirectory, 'versions');
37
+ for (const entry of await readdir(versions, { withFileTypes: true }).catch(() => [])) {
38
+ if (!entry.isDirectory() || !stableVersion(entry.name)) continue;
39
+ const path = join(versions, entry.name), packageRoot = join(path, 'node_modules', 'granttap-mcp');
40
+ const size = await bytes(path); runtime.bytes += size;
41
+ if (processes != null && !protectedRoots.includes(resolve(packageRoot)) && !processes.includes(path)) {
42
+ runtime.paths.push(path); runtime.reclaimableBytes += size;
43
+ }
44
+ }
45
+ categories.push(runtime);
46
+ return categories;
47
+ }
48
+
49
+ export async function clearComputerCache(root: string, directory = configDir()): Promise<number> {
50
+ const lockPath = join(directory, 'runtime-updates', 'install.lock');
51
+ await mkdir(join(directory, 'runtime-updates'), { recursive: true, mode: 0o700 });
52
+ let lock;
53
+ try { lock = await open(lockPath, 'wx', 0o600); }
54
+ catch { throw Error('An MCP install or cache cleanup is in progress. Wait for it to finish.'); }
55
+ try { return await removeCaches(root, directory); }
56
+ finally { await lock.close(); await unlink(lockPath); }
57
+ }
58
+
59
+ async function removeCaches(root: string, directory: string): Promise<number> {
60
+ // Re-read selection/process ownership immediately before each deletion.
61
+ const first = await computerCache(root, directory);
62
+ let removed = 0;
63
+ for (const category of first) for (const path of category.paths) {
64
+ const current = await computerCache(root, directory);
65
+ if (!current.some(c => c.paths.includes(path))) continue;
66
+ try {
67
+ if ((await lstat(path)).isSymbolicLink()) continue;
68
+ const size = await bytes(path);
69
+ await rm(path, { recursive: true, force: true }); removed += size;
70
+ } catch { /* A locked cache stays; remaining categories can still be cleared. */ }
71
+ }
72
+ return removed;
73
+ }
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { RelayClient } from '../../../../packages/core/relay-client';
3
+ import type { DeviceManagementRequest, DeviceManagementResult } from '../../../../packages/protocol/schema';
4
+ import { loadConfig, machineConfigPath } from '../config';
5
+ import { observedComputerId } from '../mesh/identity/computer';
6
+ import { clearComputerCache } from './cache';
7
+ import { providerLogins } from './login';
8
+ import { managementSnapshot, maintenanceIdle, restartProvider, restartRuntime, runtimeRoot, updateRuntime } from './maintenance';
9
+
10
+ type Send = (result: DeviceManagementResult) => Promise<void>;
11
+ type Entry = { fingerprint: string; result?: DeviceManagementResult; pending?: Promise<void>; at: number; ttl: number };
12
+ const requests = new Map<string, Entry>();
13
+ let mutating = false;
14
+ export function deviceManagementWorkActive(): boolean { return [...requests.values()].some(entry => entry.pending != null); }
15
+
16
+ export async function handleDeviceManagement(client: RelayClient, request: DeviceManagementRequest,
17
+ peer: string | undefined): Promise<boolean> {
18
+ if (!peer) return false;
19
+ if (!Number.isFinite(request.createdAt) || Math.abs(Date.now() - request.createdAt) > 120_000) return false;
20
+ const machine = loadConfig(machineConfigPath());
21
+ if (machine.role !== 'machine' || machine.room !== client.room
22
+ || (machine.peerPublicKey !== peer && !machine.extraPeerPublicKeys?.includes(peer))) return false;
23
+ const computerId = observedComputerId();
24
+ if (request.computerId != null && request.computerId !== computerId) return false;
25
+ // Sign-in material is deliberately non-durable and addressed only to the requesting paired controller.
26
+ const send: Send = value => client.sendToPeer(value, peer, { reliable: false, urgent: true, ttlMs: 60_000 });
27
+ const key = client.room + ':' + peer + ':' + request.requestId;
28
+ const fingerprint = createHash('sha256').update(JSON.stringify(request)).digest('hex');
29
+ for (const [k, value] of requests) if (Date.now() - value.at > value.ttl && !value.pending) requests.delete(k);
30
+ const previous = requests.get(key);
31
+ if (previous) {
32
+ if (previous.fingerprint !== fingerprint) return false;
33
+ if (previous.pending) await previous.pending;
34
+ if (previous.result) await send(previous.result);
35
+ return true;
36
+ }
37
+ if (requests.size >= 128) return false;
38
+ const entry: Entry = { fingerprint, at: Date.now(), ttl: request.action === 'status' ? 60_000 : 15 * 60_000 };
39
+ requests.set(key, entry);
40
+ const report: Send = async result => { entry.result = result; await send(result); };
41
+ entry.pending = execute(request, client.room + ':' + peer, computerId, report)
42
+ .catch(async () => { /* disconnected phones recover with a new status request */ })
43
+ .finally(() => { entry.pending = undefined; });
44
+ // Commands run independently of message delivery; repeated taps reuse their operation.
45
+ return true;
46
+ }
47
+
48
+ async function execute(request: DeviceManagementRequest, scope: string, computerId: string, send: Send): Promise<void> {
49
+ const base = { type: 'device.manage.result' as const, requestId: request.requestId, computerId,
50
+ action: request.action, agent: request.agent };
51
+ const result = (phase: DeviceManagementResult['phase'], message: string): DeviceManagementResult =>
52
+ ({ ...base, phase, message, createdAt: Date.now() });
53
+ const exclusive = request.action !== 'status' && !request.action.startsWith('provider-login');
54
+ if (exclusive && mutating) { await send(result('failed', 'Another computer maintenance action is in progress.')); return; }
55
+ if (exclusive) mutating = true;
56
+ try {
57
+ if (request.action.startsWith('provider-login')) {
58
+ const emit = (login: NonNullable<DeviceManagementResult['login']>) => {
59
+ const phase = login.phase === 'signed-in' || login.phase === 'cancelled' ? 'completed'
60
+ : login.phase === 'failed' || login.phase === 'expired' ? 'failed' : 'waiting';
61
+ void send({ ...result(phase, login.message), login }).catch(() => {});
62
+ };
63
+ const login = request.action === 'provider-login' ? providerLogins.start(scope, request.agent!, emit)
64
+ : request.action === 'provider-login-code' ? providerLogins.code(scope, request.agent!, request.operationId!, request.code!)
65
+ : providerLogins.cancel(scope, request.agent!, request.operationId ?? '');
66
+ await send({ ...result('waiting', login.message), login }); return;
67
+ }
68
+ await send(result('running', request.action === 'status' ? 'Reading this computer…' : 'Working on this computer…'));
69
+ if (request.action === 'status') providerLogins.replay(scope);
70
+ let message = 'Computer status refreshed.';
71
+ if (request.action === 'clear-cache') {
72
+ if (!maintenanceIdle()) throw Error('Finish active requests and sign-in before clearing computer caches.');
73
+ const bytes = await clearComputerCache(runtimeRoot);
74
+ message = `Cleared ${(bytes / 1024 / 1024).toFixed(1)} MB of disposable cache.`;
75
+ } else if (request.action === 'runtime-update' || request.action === 'runtime-install') {
76
+ message = await updateRuntime();
77
+ } else if (request.action === 'provider-restart') {
78
+ message = await restartProvider(request.agent!, request.interruptActiveTasks);
79
+ } else if (request.action === 'runtime-restart') {
80
+ // The phone waits for a different runtimeEpoch, rather than treating this acknowledgement as a restart.
81
+ if (!maintenanceIdle()) throw Error('Finish active requests and sign-in before restarting MCP.');
82
+ await send(result('waiting', 'Restart requested. Waiting for MCP to reconnect…'));
83
+ restartRuntime(); return;
84
+ }
85
+ await send({ ...result('completed', message), snapshot: await managementSnapshot() });
86
+ } catch (error) {
87
+ await send(result('failed', error instanceof Error ? error.message.slice(0, 512) : 'This action failed on the computer.'));
88
+ } finally { if (exclusive) mutating = false; }
89
+ }
@@ -0,0 +1,119 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import type { CodingAgent, ProviderLoginProgress } from '../../../../packages/protocol/schema';
4
+ import { signalOwnedProcess } from '../reply/process';
5
+ import { stripAnsi } from '../tools/version';
6
+ import { allowedLoginURL, installedProvider, loginArguments, providerCommand, providerEnvironment } from './provider-command';
7
+
8
+ type Flow = { scope: string; child: ChildProcess; progress: ProviderLoginProgress; output: string;
9
+ timer: NodeJS.Timeout; emit: (value: ProviderLoginProgress) => void };
10
+ const terminal = new Set(['signed-in', 'failed', 'cancelled', 'expired']);
11
+
12
+ /** Authentication stays in the provider CLI. Only its temporary browser challenge crosses E2EE. */
13
+ export class ProviderLogins {
14
+ private flows = new Map<string, Flow>();
15
+ constructor(private launch = spawn, private command = providerCommand, private now = Date.now) {}
16
+
17
+ active(): boolean { return [...this.flows.values()].some(f => !terminal.has(f.progress.phase)); }
18
+
19
+ /** A phone can leave for Safari while its final event is dropped. Status refresh replays only its latest flows. */
20
+ replay(scope: string): void {
21
+ const latest = new Map<CodingAgent, Flow>();
22
+ for (const flow of this.flows.values()) if (flow.scope === scope) latest.set(flow.progress.agent, flow);
23
+ for (const flow of latest.values()) flow.emit(flow.progress);
24
+ }
25
+
26
+ start(scope: string, agent: CodingAgent, emit: Flow['emit']): ProviderLoginProgress {
27
+ const existing = [...this.flows.values()].find(f => f.scope === scope && f.progress.agent === agent
28
+ && !terminal.has(f.progress.phase));
29
+ if (existing) { existing.emit = emit; return existing.progress; }
30
+ if (this.activeFor(agent)) throw Error('A sign-in for this provider is already open on another controller.');
31
+ const command = this.command(agent);
32
+ if (!installedProvider(command)) throw Error('Install this provider on the computer before signing in.');
33
+ this.prune();
34
+ if (this.flows.size >= 8) throw Error('Too many sign-in requests. Wait for an existing sign-in to expire.');
35
+ const progress: ProviderLoginProgress = { operationId: randomUUID(), agent, phase: 'starting',
36
+ expiresAt: this.now() + 10 * 60_000, message: 'Starting provider sign-in on this computer…' };
37
+ const child = this.launch(command, loginArguments(agent), { env: { ...providerEnvironment(), BROWSER: '/usr/bin/false' },
38
+ stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
39
+ const timer = setTimeout(() => this.finish(progress.operationId, 'expired', 'Sign-in expired. Start again.'), 10 * 60_000);
40
+ timer.unref();
41
+ const flow: Flow = { scope, child, progress, output: '', timer, emit };
42
+ this.flows.set(progress.operationId, flow);
43
+ child.stdout?.setEncoding('utf8'); child.stderr?.setEncoding('utf8');
44
+ child.stdout?.on('data', value => this.output(flow, String(value)));
45
+ child.stderr?.on('data', value => this.output(flow, String(value)));
46
+ child.stdin?.on('error', () => this.finish(progress.operationId, 'failed', 'The provider closed sign-in input. Start again.'));
47
+ child.on('error', () => this.finish(progress.operationId, 'failed', 'The provider could not start sign-in.'));
48
+ child.on('close', code => this.finish(progress.operationId, code === 0 ? 'signed-in' : 'failed',
49
+ code === 0 ? 'Signed in on this computer.' : 'The provider did not complete sign-in. Start again.'));
50
+ return progress;
51
+ }
52
+
53
+ private activeFor(agent: CodingAgent): boolean {
54
+ return [...this.flows.values()].some(f => f.progress.agent === agent && !terminal.has(f.progress.phase));
55
+ }
56
+
57
+ private output(flow: Flow, value: string): void {
58
+ if (terminal.has(flow.progress.phase)) return;
59
+ flow.output = stripAnsi(flow.output + value).slice(-65_536);
60
+ const urls = flow.output.match(/https:\/\/[^\s<>"\u001b]+/g) ?? [];
61
+ const verificationURL = urls.map(url => allowedLoginURL(flow.progress.agent, url.replace(/[).,]+$/, ''))).find(Boolean);
62
+ if (!verificationURL) return;
63
+ const queryCode = new URL(verificationURL).searchParams.get('user_code');
64
+ const userCode = queryCode && /^[A-Z0-9-]{3,128}$/i.test(queryCode) ? queryCode
65
+ : /(?:user|device|verification|one.time|enter(?:\s+the)?)?\s*code\s*[:\n]\s*([A-Z0-9]{3,8}(?:-[A-Z0-9]{3,8})?)/i.exec(flow.output)?.[1]
66
+ ?? /\bcode\b[^\r\n]{0,100}\r?\n\s*([A-Z0-9]{3,8}(?:-[A-Z0-9]{3,8}){1,3})\b/i.exec(flow.output)?.[1];
67
+ const phase = flow.progress.agent === 'claude' ? 'awaiting-code' : 'awaiting-browser';
68
+ if (flow.progress.verificationURL === verificationURL && flow.progress.userCode === userCode && flow.progress.phase === phase) return;
69
+ flow.progress = { ...flow.progress, phase, verificationURL, userCode,
70
+ message: phase === 'awaiting-code' ? 'Open the provider, then paste its authorization code here.'
71
+ : 'Open the provider and approve sign-in. This computer will finish automatically.' };
72
+ flow.emit(flow.progress);
73
+ }
74
+
75
+ code(scope: string, agent: CodingAgent, operationId: string, code: string): ProviderLoginProgress {
76
+ const flow = this.owned(scope, agent, operationId);
77
+ if (flow.progress.phase !== 'awaiting-code') throw Error('This sign-in is not waiting for an authorization code.');
78
+ if (/[\r\n\u0000]/.test(code) || !code.trim() || code.length > 4_096) throw Error('Paste a single authorization code.');
79
+ flow.child.stdin?.write(code.trim() + '\n');
80
+ return flow.progress;
81
+ }
82
+
83
+ cancel(scope: string, agent: CodingAgent, operationId: string): ProviderLoginProgress {
84
+ const flow = this.owned(scope, agent, operationId);
85
+ this.finish(operationId, 'cancelled', 'Sign-in cancelled. Existing credentials were not removed.');
86
+ return flow.progress;
87
+ }
88
+
89
+ private owned(scope: string, agent: CodingAgent, operationId: string): Flow {
90
+ const flow = this.flows.get(operationId);
91
+ if (!flow || flow.scope !== scope || flow.progress.agent !== agent) throw Error('This sign-in belongs to another controller or has expired.');
92
+ if (this.now() >= flow.progress.expiresAt) this.finish(operationId, 'expired', 'Sign-in expired. Start again.');
93
+ if (terminal.has(flow.progress.phase)) throw Error('This sign-in has ended.');
94
+ return flow;
95
+ }
96
+
97
+ private finish(id: string, phase: ProviderLoginProgress['phase'], message: string): void {
98
+ const flow = this.flows.get(id);
99
+ if (!flow || terminal.has(flow.progress.phase)) return;
100
+ clearTimeout(flow.timer);
101
+ if (phase !== 'signed-in') {
102
+ signalOwnedProcess(flow.child, 'SIGTERM');
103
+ const kill = setTimeout(() => {
104
+ if (flow.child.exitCode == null && flow.child.signalCode == null) signalOwnedProcess(flow.child, 'SIGKILL');
105
+ }, 1_000);
106
+ kill.unref();
107
+ }
108
+ flow.output = '';
109
+ flow.progress = { operationId: id, agent: flow.progress.agent, phase, message, expiresAt: flow.progress.expiresAt };
110
+ flow.emit(flow.progress);
111
+ }
112
+
113
+ private prune(): void {
114
+ for (const [id, f] of this.flows) {
115
+ if (terminal.has(f.progress.phase) && this.now() >= f.progress.expiresAt) this.flows.delete(id);
116
+ }
117
+ }
118
+ }
119
+ export const providerLogins = new ProviderLogins();
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir, hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn, execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import type { CodingAgent, DeviceManagementSnapshot } from '../../../../packages/protocol/schema';
9
+ import { configDir } from '../config/runtime/paths';
10
+ import { observedComputerId } from '../mesh/identity/computer';
11
+ import { automaticUpdatesAllowed } from '../runtime-updates/preferences/storage.mjs';
12
+ import { readState, runtimeVersion } from '../runtime-updates/selection.mjs';
13
+ import { checkForRuntimeUpdate } from '../runtime-updates';
14
+ import { binaryVersion } from '../tools/version';
15
+ import { providerCommand, providerAuthentication, installedProvider, providerEnvironment } from './provider-command';
16
+ import { computerCache } from './cache';
17
+ import { providerLogins } from './login';
18
+ import { ownedProviderProcesses, signalOwnedProcess } from '../reply/process';
19
+ import { approvalsStatus } from '../approvals/state';
20
+
21
+ export const runtimeRoot = fileURLToPath(new URL('../../../../', import.meta.url));
22
+ const runtimeEpoch = randomUUID();
23
+ const agents: CodingAgent[] = ['grok', 'claude', 'codex', 'cursor'];
24
+
25
+ export function runtimeRestartSupported(): boolean {
26
+ if (process.platform !== 'darwin' || !process.getuid || !runtimeRoot.includes('/node_modules/')) return false;
27
+ return ['com.granttap.monitor', 'com.granttap.mcp-http'].every(label => {
28
+ try {
29
+ const plist = readFileSync(join(homedir(), 'Library', 'LaunchAgents', label + '.plist'), 'utf8');
30
+ return plist.includes('<string>' + label + '</string>') && plist.includes('/granttap-mcp/bin/granttap-mcp.mjs')
31
+ && plist.includes('<key>KeepAlive</key>');
32
+ } catch { return false; }
33
+ });
34
+ }
35
+
36
+ export function maintenanceIdle(): boolean {
37
+ return !providerLogins.active() && approvalsStatus().pending.length === 0
38
+ && agents.every(agent => ownedProviderProcesses(providerCommand(agent)).length === 0);
39
+ }
40
+
41
+ export async function managementSnapshot(): Promise<DeviceManagementSnapshot> {
42
+ const directory = join(configDir(), 'runtime-updates');
43
+ const selected = readState(directory);
44
+ const providers = await Promise.all(agents.map(async agent => {
45
+ const command = providerCommand(agent), installed = installedProvider(command);
46
+ return { agent, installed, version: binaryVersion(command), authentication: await providerAuthentication(agent, command),
47
+ loginSupported: installed, restartSupported: installed,
48
+ activeRequests: ownedProviderProcesses(command).length };
49
+ }));
50
+ return { computerId: observedComputerId(), computerName: hostname(), observedAt: Date.now(),
51
+ runtimeVersion: runtimeVersion(runtimeRoot), runtimeEpoch, selectedRuntimeVersion: selected.version,
52
+ automaticUpdatesEnabled: automaticUpdatesAllowed(directory),
53
+ runtimeUpdateSupported: runtimeRoot.includes('/node_modules/'), runtimeRestartSupported: runtimeRestartSupported(),
54
+ cache: (await computerCache(runtimeRoot)).map(({ paths: _paths, ...category }) => category), providers };
55
+ }
56
+
57
+ export async function updateRuntime(): Promise<string> {
58
+ if (!runtimeRoot.includes('/node_modules/')) throw Error('This is a development checkout. Update the installed MCP instead.');
59
+ const candidate = await checkForRuntimeUpdate({ root: runtimeRoot, stateDirectory: join(configDir(), 'runtime-updates'),
60
+ idle: maintenanceIdle, manual: true });
61
+ return candidate ? 'MCP update verified and selected. Restart MCP to activate it.'
62
+ : 'No newer published MCP is available, or activation is waiting for active work. The current MCP is retained.';
63
+ }
64
+
65
+ /** A detached, fixed command survives the monitor exiting. No phone-supplied command or path is executed. */
66
+ export function restartRuntime(): void {
67
+ if (!runtimeRestartSupported()) throw Error('Managed MCP services are not installed on this computer.');
68
+ if (!maintenanceIdle()) throw Error('Finish active requests, decisions and sign-in before restarting MCP.');
69
+ const domain = 'gui/' + process.getuid!();
70
+ const script = `setTimeout(() => { const { spawnSync } = require('node:child_process');
71
+ for (const label of ['com.granttap.mcp-http','com.granttap.monitor'])
72
+ spawnSync('/bin/launchctl',['kickstart','-k',${JSON.stringify(domain)}+'/'+label],{stdio:'ignore',timeout:10000}); }, 1200);`;
73
+ const child = spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' });
74
+ child.unref();
75
+ }
76
+
77
+ export async function restartProvider(agent: CodingAgent, interrupt = false): Promise<string> {
78
+ const command = providerCommand(agent);
79
+ if (!installedProvider(command)) throw Error('This provider is not installed on the computer.');
80
+ if (providerLogins.active()) throw Error('Finish sign-in before restarting a provider.');
81
+ const children = ownedProviderProcesses(command);
82
+ if (children.length && !interrupt) throw Error('This provider has active GrantTap requests. Confirm interruption before restarting.');
83
+ await Promise.all(children.map(child => new Promise<void>(resolve => {
84
+ const timer = setTimeout(() => { signalOwnedProcess(child, 'SIGKILL'); resolve(); }, 2_000);
85
+ child.once('close', () => { clearTimeout(timer); resolve(); });
86
+ (child as typeof child & { granttapStopped?: boolean }).granttapStopped = true;
87
+ signalOwnedProcess(child, 'SIGTERM');
88
+ })));
89
+ await promisify(execFile)(command, ['--version'], { env: providerEnvironment(), timeout: 15_000, maxBuffer: 16_384 });
90
+ return 'A fresh provider CLI started successfully. The next message starts a new GrantTap connection to it.';
91
+ }
@@ -0,0 +1,89 @@
1
+ import { accessSync, constants, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, dirname, join } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import type { CodingAgent } from '../../../../packages/protocol/schema';
6
+ import { executableOnPath, resolveClaudeBinary } from '../providers/claude-bin';
7
+ import { resolveCodexBinary } from '../providers/codex-bin';
8
+ import { resolveGrokBinary } from '../providers/grok-bin';
9
+ import { resolveCursorAgentBin } from '../reply/process/cursor-agent-bin';
10
+
11
+ export function providerCommand(agent: CodingAgent): string {
12
+ switch (agent) {
13
+ case 'claude': return resolveClaudeBinary().path;
14
+ case 'codex': return resolveCodexBinary();
15
+ case 'grok': return resolveGrokBinary();
16
+ case 'cursor': return resolveCursorAgentBin();
17
+ }
18
+ }
19
+
20
+ export function providerEnvironment(): NodeJS.ProcessEnv {
21
+ const node = dirname(process.execPath);
22
+ return { ...process.env, PATH: [node, process.env.PATH ?? ''].join(delimiter),
23
+ NO_COLOR: '1', FORCE_COLOR: '0', DISABLE_AUTOUPDATER: '1', GROK_DISABLE_AUTOUPDATER: '1' };
24
+ }
25
+
26
+ export function loginArguments(agent: CodingAgent): string[] {
27
+ if (agent === 'grok' || agent === 'codex') return ['login', '--device-auth'];
28
+ return agent === 'claude' ? ['auth', 'login'] : ['login'];
29
+ }
30
+
31
+ /** Only known provider entry points are offered; loopback callbacks and token URLs are never relayed. */
32
+ export function allowedLoginURL(agent: CodingAgent, value: string): string | undefined {
33
+ try {
34
+ const u = new URL(value);
35
+ if (u.protocol !== 'https:' || u.username || u.password) return;
36
+ if ([...u.searchParams.keys()].some(k => /^(access_token|refresh_token|id_token|client_secret)$/i.test(k))) return;
37
+ const domains: Record<CodingAgent, string[]> = {
38
+ grok: ['grok.com', 'x.ai'], codex: ['openai.com', 'chatgpt.com'],
39
+ claude: ['claude.ai', 'claude.com', 'anthropic.com'], cursor: ['cursor.com', 'cursor.sh'],
40
+ };
41
+ if (domains[agent].some(domain => u.hostname === domain || u.hostname.endsWith('.' + domain))) return u.href;
42
+ if (agent === 'grok' && process.env.GROK_OIDC_ISSUER
43
+ && new URL(process.env.GROK_OIDC_ISSUER).hostname === u.hostname) return u.href;
44
+ } catch { /* malformed or non-provider output */ }
45
+ }
46
+
47
+ export function installedProvider(command: string): boolean {
48
+ try { accessSync(command.includes('/') ? command : executableOnPath(command, process.env) ?? command, constants.X_OK); return true; } catch { return false; }
49
+ }
50
+
51
+ type Authentication = 'signed-in' | 'signed-out' | 'unknown' | 'not-installed';
52
+
53
+ /** Presence is reported separately from a successful agent turn; credentials never leave their native store. */
54
+ export async function providerAuthentication(agent: CodingAgent, command = providerCommand(agent)): Promise<Authentication> {
55
+ if (!installedProvider(command)) return 'not-installed';
56
+ if (agent === 'grok') {
57
+ try {
58
+ const file = join(process.env.GRANTTAP_GROK_DIR ?? process.env.GROK_HOME ?? join(homedir(), '.grok'), 'auth.json');
59
+ const data = JSON.parse(readFileSync(file, 'utf8'));
60
+ return Object.values(data).some(value => value && typeof value === 'object'
61
+ && typeof (value as { key?: unknown }).key === 'string') ? 'signed-in' : 'signed-out';
62
+ } catch { return process.env.XAI_API_KEY ? 'signed-in' : 'unknown'; }
63
+ }
64
+ const args = agent === 'claude' ? ['auth', 'status', '--json']
65
+ : agent === 'codex' ? ['login', 'status'] : ['status'];
66
+ return new Promise(resolve => {
67
+ let done = false, output = '';
68
+ const child = spawn(command, args, { env: providerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] });
69
+ const finish = (value: Authentication) => {
70
+ if (done) return; done = true; clearTimeout(timer); resolve(value);
71
+ };
72
+ const timer = setTimeout(() => { child.kill('SIGTERM'); finish('unknown'); }, 8_000);
73
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
74
+ child.stdout.on('data', chunk => { output = (output + chunk).slice(-16_384); });
75
+ child.stderr.on('data', chunk => { output = (output + chunk).slice(-16_384); });
76
+ child.on('error', () => finish('unknown'));
77
+ child.on('close', code => {
78
+ if (agent === 'claude') {
79
+ try {
80
+ const value = JSON.parse(output);
81
+ if (typeof value.loggedIn === 'boolean') return finish(value.loggedIn ? 'signed-in' : 'signed-out');
82
+ } catch { /* an old CLI can only provide unknown status */ }
83
+ }
84
+ if (agent === 'codex') return finish(code === 0 ? 'signed-in' : /not logged|not signed/i.test(output) ? 'signed-out' : 'unknown');
85
+ if (/not logged|not signed|unauthenticated/i.test(output)) return finish('signed-out');
86
+ finish(code === 0 && /logged in|authenticated|signed in/i.test(output) ? 'signed-in' : 'unknown');
87
+ });
88
+ });
89
+ }
@@ -1,4 +1,5 @@
1
1
  import { handleTaskTransferCommand } from "../../task-transfer/runtime";
2
+ import { handleDeviceManagement } from '../../device-management';
2
3
  import { handleProjectTransportConfiguration } from '../../task-network/runtime';
3
4
  import { handleHookTrust } from "../../codex-hook-trust";
4
5
  import { handleConfigurationCommand } from "../../configuration-delivery";
@@ -156,6 +157,7 @@ function handlePhoneUserMessage(client: RelayClient, payload: Extract<Payload, {
156
157
 
157
158
  function handlePeerConfiguration(client: RelayClient, payload: Payload, peerPublicKey: string | undefined,
158
159
  publish: (forceHistory?: boolean) => Promise<void>): Promise<boolean> | undefined {
160
+ if (payload.type === 'device.manage') return handleDeviceManagement(client, payload, peerPublicKey);
159
161
  if (payload.type === 'project.transport.set') {
160
162
  return handleProjectTransportConfiguration(client, payload, peerPublicKey);
161
163
  }
@@ -1,4 +1,6 @@
1
1
  import { runtimeWorkTracker } from "../../runtime-updates/readiness";
2
+ import { providerLogins } from '../../device-management/login';
3
+ import { deviceManagementWorkActive } from '../../device-management';
2
4
  import { refreshCodexHooks } from "../../codex-hook-trust";
3
5
  import { computerDisplayName } from "../../../../../packages/core/computer-name";
4
6
  import { RelayClient } from "../../../../../packages/core/relay-client";
@@ -170,7 +172,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
170
172
  let historyRead: Promise<SessionsStatus["sessions"]> | undefined;
171
173
  let lastHistoryPublishedAt = 0;
172
174
  let closed = false;
173
- const work = runtimeWorkTracker(() => approvalsStatus().pending.length > 0);
175
+ const work = runtimeWorkTracker(() => approvalsStatus().pending.length > 0 || providerLogins.active() || deviceManagementWorkActive());
174
176
  const loadLoop = monitorLoadLoop(client, leadership);
175
177
 
176
178
  const history = (force = false): Promise<SessionsStatus["sessions"]> => {
@@ -0,0 +1,10 @@
1
+ # Confirmed ACP file changes
2
+
3
+ `confirmedUpdateChanges` in `index.ts` projects bounded native diff content after
4
+ its owning tool reports successful completion. Grok and Cursor share this
5
+ projection. Pending, failed and unrelated updates cannot establish file edits.
6
+ Sensitive paths, traversal, malformed content and excessive files are excluded;
7
+ previews redact secrets and state truncation. Behavioral tests live alongside
8
+ the consuming Grok and Cursor conversation features.
9
+
10
+ Licensed under the repository-root GrantTap Commercial Source License.
@@ -0,0 +1,27 @@
1
+ import type { RecordedFileChange } from '../../transcript-history/changes';
2
+ import { sensitivePath } from '../../sessions/support/edit-stats';
3
+ import { redactSecrets } from '../../sessions/telemetry/command-preview';
4
+
5
+ /** Native ACP diffs are consulted only by the successful tool completion path. */
6
+ export function confirmedUpdateChanges(content: unknown): RecordedFileChange[] {
7
+ if (!Array.isArray(content)) return [];
8
+ return content.slice(0, 64).flatMap(raw => {
9
+ if (!raw || typeof raw !== 'object' || raw.type !== 'diff' || typeof raw.path !== 'string'
10
+ || !raw.path || raw.path.length > 2048 || sensitivePath(raw.path)
11
+ || /[\\\x00-\x1f]/.test(raw.path) || raw.path.split('/').includes('..')
12
+ || typeof raw.oldText !== 'string' || typeof raw.newText !== 'string'
13
+ || raw.oldText.length + raw.newText.length > 1_048_576) return [];
14
+ const split = (text: string): string[] => text ? text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n') : [];
15
+ const before = split(raw.oldText), after = split(raw.newText);
16
+ let start = 0, endBefore = before.length, endAfter = after.length;
17
+ while (start < endBefore && start < endAfter && before[start] === after[start]) start++;
18
+ while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) {
19
+ endBefore--; endAfter--;
20
+ }
21
+ const removed = before.slice(start, endBefore), added = after.slice(start, endAfter);
22
+ if (!removed.length && !added.length) return [];
23
+ const diff = redactSecrets([...removed.map(line => '-' + line), ...added.map(line => '+' + line)].join('\n'));
24
+ return [{ path: raw.path, linesAdded: added.length, linesRemoved: removed.length,
25
+ diff: diff.slice(0, 16_384), ...(diff.length > 16_384 ? { diffTruncated: true } : {}) }];
26
+ });
27
+ }
@@ -0,0 +1,21 @@
1
+ # Persisted Grok conversation
2
+
3
+ [`readGrokConversation`](index.ts) reads native `updates.jsonl`, the ACP restore
4
+ transcript. Model `chat_history.jsonl` may be compacted and is only a fallback
5
+ when the persisted transcript is absent. Thinking, hook output, raw tool output
6
+ and model-only summaries never become visible messages.
7
+
8
+ Visible text chunks keep a stable native record identity while streaming.
9
+ Successful `turn_completed` marks only the current answer as final. Tool calls
10
+ use native IDs, and file changes require completion of that exact call. Native
11
+ diffs are bounded replacement hunks; sensitive files remain excluded.
12
+
13
+ The incremental reader retains at most eight session projections, reads at most
14
+ 64 MiB per pass, accepts at most 8 MiB per JSON record and retains at most 4,096
15
+ visible entries and 8 MiB of projected data. Individual visible messages are
16
+ bounded to 64 KiB, and at most 256 pending tool completions are retained. Full transfer capture refuses incomplete or bounded input
17
+ instead of calling a partial archive complete. Older CLIs without `updates.jsonl`
18
+ retain their previous reader. Tests live in
19
+ `../../sessions/scan/tests/grok-persisted-updates.test.ts`.
20
+
21
+ Licensed under the repository-root GrantTap Commercial Source License.