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.
- package/README.md +7 -0
- package/apps/bridge/src/codex-desktop/README.md +5 -0
- package/apps/bridge/src/codex-desktop/index.ts +2 -1
- package/apps/bridge/src/codex-desktop/question-attachment-envelope.ts +23 -0
- package/apps/bridge/src/codex-questions/README.md +7 -0
- package/apps/bridge/src/codex-questions/index.ts +4 -2
- package/apps/bridge/src/monitor/handlers/user-message.ts +9 -10
- package/apps/bridge/src/monitor/message-delivery/README.md +5 -0
- package/apps/bridge/src/monitor/message-delivery/index.ts +10 -3
- package/apps/bridge/src/runtime-updates/README.md +7 -1
- package/apps/bridge/src/runtime-updates/index.ts +7 -1
- package/apps/bridge/src/runtime-updates/preferences/README.md +18 -0
- package/apps/bridge/src/runtime-updates/preferences/index.ts +12 -0
- package/apps/bridge/src/runtime-updates/preferences/storage.d.mts +4 -0
- package/apps/bridge/src/runtime-updates/preferences/storage.mjs +35 -0
- package/apps/bridge/src/runtime-updates/supervision.mjs +3 -2
- package/apps/bridge/src/sessions/README.md +5 -0
- package/apps/bridge/src/sessions/catalog.ts +7 -3
- package/apps/mcp/src/connection-center/README.md +7 -0
- package/apps/mcp/src/connection-center/bridge.js +2 -2
- package/apps/mcp/src/connection-center/state.ts +3 -0
- package/apps/mcp/src/connection-center/update-controls.ts +23 -0
- package/apps/mcp/src/connection-center/view.js +12 -0
- package/apps/mcp/src/connection-center/widget.html +1 -0
- package/apps/mcp/src/mcp-tools/README.md +6 -1
- package/apps/mcp/src/mcp-tools/connect/index.ts +4 -2
- package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
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.
|
|
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(
|
|
59
|
+
function sortedUniqueSessions(sessions: SessionInfo[]): SessionInfo[] {
|
|
60
60
|
const merged = new Map<string, SessionInfo>();
|
|
61
|
-
for (const session of
|
|
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:
|
|
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
|
|
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
|
|
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 & 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
|
-
`
|
|
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/
|
|
3
|
+
export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v5.html";
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.105
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.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.
|
|
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
|
@@ -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(),
|