granttap-mcp 0.8.102 → 0.8.105

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 (32) hide show
  1. package/README.md +7 -0
  2. package/apps/bridge/src/codex-desktop/README.md +5 -0
  3. package/apps/bridge/src/codex-desktop/index.ts +2 -1
  4. package/apps/bridge/src/codex-desktop/question-attachment-envelope.ts +23 -0
  5. package/apps/bridge/src/codex-questions/README.md +7 -0
  6. package/apps/bridge/src/codex-questions/index.ts +4 -2
  7. package/apps/bridge/src/monitor/handlers/user-message.ts +9 -10
  8. package/apps/bridge/src/monitor/message-delivery/README.md +5 -0
  9. package/apps/bridge/src/monitor/message-delivery/index.ts +10 -3
  10. package/apps/bridge/src/runtime-updates/README.md +7 -1
  11. package/apps/bridge/src/runtime-updates/index.ts +7 -1
  12. package/apps/bridge/src/runtime-updates/preferences/README.md +18 -0
  13. package/apps/bridge/src/runtime-updates/preferences/index.ts +12 -0
  14. package/apps/bridge/src/runtime-updates/preferences/storage.d.mts +4 -0
  15. package/apps/bridge/src/runtime-updates/preferences/storage.mjs +35 -0
  16. package/apps/bridge/src/runtime-updates/supervision.mjs +3 -2
  17. package/apps/bridge/src/sessions/README.md +5 -0
  18. package/apps/bridge/src/sessions/catalog.ts +7 -3
  19. package/apps/mcp/src/connection-center/README.md +7 -0
  20. package/apps/mcp/src/connection-center/bridge.js +2 -2
  21. package/apps/mcp/src/connection-center/state.ts +3 -0
  22. package/apps/mcp/src/connection-center/update-controls.ts +23 -0
  23. package/apps/mcp/src/connection-center/view.js +12 -0
  24. package/apps/mcp/src/connection-center/widget.html +1 -0
  25. package/apps/mcp/src/mcp-tools/README.md +6 -1
  26. package/apps/mcp/src/mcp-tools/connect/index.ts +4 -2
  27. package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
  28. package/cursor-plugin/README.md +1 -1
  29. package/cursor-plugin/mcp.json +1 -1
  30. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  31. package/package.json +1 -1
  32. package/packages/protocol/messages/interaction/index.ts +4 -0
package/README.md CHANGED
@@ -862,3 +862,10 @@ Installed GrantTap background services check for stable MCP updates automaticall
862
862
  Verified updates are staged separately and applied after active requests and pending
863
863
  decisions finish. Startup failures restore the previous version. Pairing and task
864
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.
@@ -29,3 +29,8 @@ consumed them. Tests exercise native delivery, framing, socket trust, durable
29
29
  deduplication, and refusal without launching the CLI.
30
30
 
31
31
  License: GrantTap Commercial Source License in the repository-root `LICENSE`.
32
+
33
+ Document staging for a native-only question keeps the file note inside its
34
+ original answer envelope. The host's native question and all paired controllers
35
+ therefore observe the same question item as answered; document paths remain
36
+ available to the admitted turn. Ordinary chat text is unchanged.
@@ -1,3 +1,4 @@
1
+ import { nativeQuestionAttachmentEnvelope } from './question-attachment-envelope';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import type { SessionInfo } from '../../../../packages/protocol/schema';
@@ -72,7 +73,7 @@ function turnRequest(input: { session: SessionInfo; text: string; images: string
72
73
  networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false }
73
74
  : access === 'full' ? { type: 'dangerFullAccess' } : undefined;
74
75
  return { threadId: session.sessionId, clientUserMessageId: options.messageId ?? randomUUID(),
75
- input: [{ type: 'text', text, text_elements: [] }, ...images.map(path => ({ type: 'localImage', path }))],
76
+ input: [{ type: 'text', text: options.nativeOnly ? nativeQuestionAttachmentEnvelope(text) : text, text_elements: [] }, ...images.map(path => ({ type: 'localImage', path }))],
76
77
  ...(options.model ? { model: options.model } : {}), ...(effort ? { effort } : {}),
77
78
  ...(sandboxPolicy ? { sandboxPolicy } : {}) };
78
79
  }
@@ -0,0 +1,23 @@
1
+ /** Preserve the native question envelope after document staging appends its local file note. */
2
+ export function nativeQuestionAttachmentEnvelope(text: string): string {
3
+ const opening = '<send_user_message_question_reply>';
4
+ const closing = '</send_user_message_question_reply>';
5
+ const boundary = text.lastIndexOf(closing);
6
+ if (!text.startsWith(opening) || boundary < opening.length) return text;
7
+ const note = text.slice(boundary + closing.length);
8
+ const header = '\n\nAttached files available locally:\n';
9
+ if (!note.startsWith(header)) return text;
10
+ const files = note.slice(header.length).split('\n');
11
+ if (!files.length || files.some(line => !/^- .+: .+$/.test(line))) return text;
12
+ try {
13
+ const rows = JSON.parse(text.slice(opening.length, boundary));
14
+ if (!Array.isArray(rows) || rows.length !== 1 || typeof rows[0]?.questionItemId !== 'string'
15
+ || typeof rows[0]?.answer !== 'string') return text;
16
+ const identity = JSON.parse(rows[0].questionItemId);
17
+ if (!Array.isArray(identity) || identity.length !== 3 || identity[0] !== 'request_user_input_async'
18
+ || typeof identity[1] !== 'string' || !/^[a-zA-Z0-9_-]{1,180}$/.test(identity[1])
19
+ || !Number.isInteger(identity[2]) || identity[2] < 0 || identity[2] > 3) return text;
20
+ rows[0].answer += note;
21
+ return opening + '\n' + JSON.stringify(rows) + '\n' + closing;
22
+ } catch { return text; }
23
+ }
@@ -25,3 +25,10 @@ Tests under `tests/` cover publication, reconnect replay, native acknowledgement
25
25
  scope rejection, expiry and delivery through an isolated native IPC fixture.
26
26
 
27
27
  License: GrantTap Commercial Source License in the repository-root `LICENSE`.
28
+
29
+ Attachment-capable controllers opt in using the advertised
30
+ `questionReplyFormat: attachments-v1`. The original question item owns the
31
+ answer, image and file payload together; ordinary encrypted attachment transport,
32
+ Task policy, native-owner admission and stable question idempotency still apply.
33
+ Legacy text-only replies keep their existing contract. Files remain available
34
+ for the admitted native turn; another phone cannot start a second answer.
@@ -40,6 +40,7 @@ export async function publishCodexQuestions(client: RelayClient, sessions: Sessi
40
40
  } else if (!record.announced || options.replay) {
41
41
  const text = [record.question, ...record.options.map(value => '• ' + value)].join('\n');
42
42
  await client.send({ type: 'agent.event', kind: 'question', requestId: record.requestId,
43
+ questionReplyFormat: 'attachments-v1',
43
44
  sessionId: session.sessionId, agent: 'codex', projectId: session.projectId,
44
45
  taskId: session.taskId, computerId: session.computerId, text, createdAt: record.createdAt },
45
46
  'phone', { ttlMs: Math.max(1, record.createdAt + RETENTION_MS - Date.now()),
@@ -73,8 +74,9 @@ export async function handleCodexQuestionReply(client: RelayClient, message: Use
73
74
  await resolveQuestion(client, record, peerPublicKey, !record.answered);
74
75
  return true;
75
76
  }
76
- if (!message.text.trim() || message.text.length > 4_000 || message.attachments?.length
77
- || message.attachmentRefs?.length) return false;
77
+ const hasAttachments = !!(message.attachments?.length || message.attachmentRefs?.length);
78
+ if (!message.text.trim() || message.text.length > 4_000
79
+ || (hasAttachments && message.questionReplyFormat !== 'attachments-v1')) return false;
78
80
  return deliver(client, { ...message, requestId: undefined,
79
81
  messageId: message.messageId ?? 'codex-answer-' + record.requestId,
80
82
  text: nativeAnswerEnvelope(record, message.text) }, publish, peerPublicKey,
@@ -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);
@@ -14,7 +14,13 @@ failed version is quarantined until a newer release appears.
14
14
 
15
15
  Pairing, account credentials, task history, capability policy, and provider
16
16
  installations are not modified by runtime updates. Offline checks retry later.
17
- Source checkouts and test processes do not update themselves. Administrators may
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
18
24
  set `GRANTTAP_DISABLE_AUTO_UPDATE=1` on their service to retain their installed
19
25
  version. Private staging consumes space; old versions are retained for rollback.
20
26
 
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
  import { configDir } from '../config/runtime/paths';
5
5
  import { latestRelease, trustedRelease, installRelease, verifyRelease, type Release } from './registry';
6
6
  import { newerVersion, readState, runtimeVersion, saveState, validRuntime, versionRoot } from './selection.mjs';
7
+ import { automaticUpdatesAllowed } from './preferences/storage.mjs';
8
+ export { runtimeUpdateSettings, setRuntimeAutoUpdate } from './preferences';
7
9
 
8
10
  type Options={root:string;stateDirectory:string;now?:()=>number;latest?:()=>Promise<unknown>;
9
11
  idle:()=>boolean;install?:(release:Release,directory:string)=>Promise<void>;
@@ -11,11 +13,13 @@ type Options={root:string;stateDirectory:string;now?:()=>number;latest?:()=>Prom
11
13
 
12
14
  /** Stage verified releases independently; activation waits until no request or decision is pending. */
13
15
  export async function checkForRuntimeUpdate(options:Options):Promise<string|undefined> {
16
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
14
17
  mkdirSync(options.stateDirectory,{recursive:true,mode:0o700});
15
18
  const lock=join(options.stateDirectory,'install.lock');let fd:number;
16
19
  try {fd=openSync(lock,'wx',0o600);} catch {return undefined;}
17
20
  try {
18
21
  const release=await (options.latest??latestRelease)();
22
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
19
23
  const state=readState(options.stateDirectory);
20
24
  if(!trustedRelease(release) || !newerVersion(release.version,runtimeVersion(options.root))
21
25
  || state.rejectedVersion===release.version || (state.version && !newerVersion(release.version,state.version)))return;
@@ -23,9 +27,10 @@ export async function checkForRuntimeUpdate(options:Options):Promise<string|unde
23
27
  if(!validRuntime(candidate,options.stateDirectory,release.version)) {
24
28
  await (options.install??installRelease)(release,options.stateDirectory);
25
29
  }
30
+ if(!automaticUpdatesAllowed(options.stateDirectory))return;
26
31
  await (options.verify??verifyRelease)(candidate);
27
32
  if(!validRuntime(candidate,options.stateDirectory,release.version))throw Error('Runtime verification failed');
28
- if(!options.idle())return;
33
+ if(!options.idle() || !automaticUpdatesAllowed(options.stateDirectory))return;
29
34
  saveState(options.stateDirectory,{root:candidate,version:release.version,
30
35
  previous:state.root?{root:state.root,version:state.version}:undefined});
31
36
  options.activate?.(candidate);return candidate;
@@ -42,6 +47,7 @@ export function runtimeUpdateLoop(options:LoopOptions):()=>void {
42
47
  const tick=async()=>{
43
48
  if(stopped)return;
44
49
  try {
50
+ if(!automaticUpdatesAllowed(options.directory)){lastCheck=0;return;}
45
51
  const lock=join(options.directory,'install.lock');
46
52
  try {if(now()-statSync(lock).mtimeMs>10*60_000)unlinkSync(lock);} catch { /* no stale lock */ }
47
53
  if(now()-lastCheck>=4*60*60_000) {
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { selectedRuntime, rollbackRuntime } from './selection.mjs';
2
+ import { automaticUpdatesAllowed } from './preferences/storage.mjs';
2
3
 
3
4
  /** Monitor only the child started by this launcher; external providers are never stopped. */
4
5
  export function superviseRuntime(child, options) {
@@ -9,14 +10,14 @@ export function superviseRuntime(child, options) {
9
10
  const stop=()=>{if(stopped)return;stopped=true;clearInterval(timer);child.off('message',onMessage);};
10
11
  const onMessage=message=>{
11
12
  if(message?.type==='granttap.runtime-healthy')healthy=true;
12
- if(message?.type==='granttap.runtime-update-ready' && changed()) {stop();child.kill('SIGTERM');}
13
+ if(message?.type==='granttap.runtime-update-ready' && automaticUpdatesAllowed(directory) && changed()) {stop();child.kill('SIGTERM');}
13
14
  };
14
15
  const tick=()=>{
15
16
  if(stopped)return;
16
17
  if(!healthy && (options.now??Date.now)()-started>120_000) {
17
18
  rollbackRuntime(bundled,directory,root);stop();child.kill('SIGTERM');return;
18
19
  }
19
- if(changed() && child.connected)child.send({type:'granttap.runtime-update-check'},()=>{});
20
+ if(automaticUpdatesAllowed(directory) && changed() && child.connected)child.send({type:'granttap.runtime-update-check'},()=>{});
20
21
  };
21
22
  const timer=(options.schedule??setInterval)(tick,10_000);timer.unref?.();child.on('message',onMessage);
22
23
  return {stop,onExit:(code,signal)=>{
@@ -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,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
 
@@ -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.102
13
+ npm install -g granttap-mcp@0.8.105
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.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"
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.105\";\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.102";
10
+ const PACKAGE = "granttap-mcp@0.8.105";
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.102",
3
+ "version": "0.8.105",
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": {
@@ -49,6 +49,8 @@ export const UserMessage = z.object({
49
49
  agent: CodingAgent.optional(),
50
50
  cwd: z.string().max(4_096).optional(),
51
51
  requestId: z.string().optional(),
52
+ /** Explicit compatible opt-in for native question file/image replies. */
53
+ questionReplyFormat: z.literal("attachments-v1").optional(),
52
54
  sessionId: z.string().optional(),
53
55
  attachments: z.array(UserAttachment).max(MAX_USER_ATTACHMENTS).optional(),
54
56
  /** Attachments that came ahead of this message, by id. */
@@ -122,6 +124,8 @@ export const AgentEvent = z.object({
122
124
  type: z.literal("agent.event"),
123
125
  text: z.string(),
124
126
  requestId: z.string().optional(),
127
+ /** Explicit compatible opt-in for native question file/image replies. */
128
+ questionReplyFormat: z.literal("attachments-v1").optional(),
125
129
  kind: z.enum(["question", "status", "response"]).optional(),
126
130
  sessionId: z.string().optional(),
127
131
  agent: CodingAgent.optional(),