granttap-mcp 0.8.99 → 0.8.101
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/apps/bridge/src/codex-questions/README.md +27 -0
- package/apps/bridge/src/codex-questions/index.ts +89 -0
- package/apps/bridge/src/codex-questions/observations.ts +63 -0
- package/apps/bridge/src/codex-questions/records.ts +37 -0
- package/apps/bridge/src/codex-questions/source.ts +19 -0
- package/apps/bridge/src/monitor/handlers/inbound.ts +13 -1
- package/apps/bridge/src/monitor/handlers/user-message.ts +3 -1
- package/apps/bridge/src/monitor/message-delivery/index.ts +2 -1
- package/apps/bridge/src/monitor/support/runtime.ts +23 -0
- package/apps/bridge/src/reply/payload/types.ts +2 -0
- package/apps/bridge/src/reply/process/codex-headless.ts +1 -0
- package/apps/bridge/src/sessions/host-heartbeat/README.md +14 -0
- package/apps/bridge/src/sessions/host-heartbeat/index.ts +47 -0
- package/apps/bridge/src/sessions/native-source.ts +1 -1
- package/apps/bridge/src/sessions/scan/codex/activity/rows.ts +12 -0
- package/apps/bridge/src/sessions/scan/codex/parse.ts +6 -2
- package/apps/bridge/src/sessions/support/activity-helpers.ts +5 -1
- package/apps/bridge/src/transcript-history/index.ts +3 -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/core/outbound-scheduler/README.md +6 -0
- package/packages/core/outbound-scheduler/index.ts +2 -2
- package/packages/core/relay-client-types.ts +2 -0
- package/packages/core/relay-client.ts +3 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Codex desktop questions
|
|
2
|
+
|
|
3
|
+
Public entry: `index.ts`. Native `request_user_input_async` questions are read
|
|
4
|
+
from discovered Codex rollouts and sent to paired controllers as ordinary
|
|
5
|
+
GrantTap question cards. Choice labels remain visible in the question; the
|
|
6
|
+
person can type or speak the answer. The question, Task and native call retain
|
|
7
|
+
their identities. No new provider, approval policy or pairing is created.
|
|
8
|
+
|
|
9
|
+
The monitor checks only bounded tails of known chats every two seconds. Unchanged
|
|
10
|
+
sources reuse their revision. Pending questions survive monitor restart and are
|
|
11
|
+
replayed when the phone reconnects or refreshes; repeat scans do not send another
|
|
12
|
+
wake notification. A native answer clears the corresponding card across devices.
|
|
13
|
+
After a day, a GrantTap card expires without claiming the native question was
|
|
14
|
+
answered. The Codex conversation remains the source of truth.
|
|
15
|
+
|
|
16
|
+
Replies use the original host question item ID and normal scoped phone delivery,
|
|
17
|
+
including current provider, pause and Task policy. Native admission uses one
|
|
18
|
+
stable delivery ID per question, so concurrent controllers cannot submit two
|
|
19
|
+
different answers. The phone's own message ID still receives its receipts.
|
|
20
|
+
If the desktop owner is unavailable, question replies do not launch a separate
|
|
21
|
+
CLI writer. An uncertain admission is not replayed or described as a confirmed
|
|
22
|
+
native answer. Native transcript files are read without modification.
|
|
23
|
+
|
|
24
|
+
Tests under `tests/` cover publication, reconnect replay, native acknowledgement,
|
|
25
|
+
scope rejection, expiry and delivery through an isolated native IPC fixture.
|
|
26
|
+
|
|
27
|
+
License: GrantTap Commercial Source License in the repository-root `LICENSE`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RelayClient } from '../../../../packages/core/relay-client';
|
|
2
|
+
import type { SessionInfo, UserMessage } from '../../../../packages/protocol/schema';
|
|
3
|
+
import { nativeTranscriptSource } from '../sessions';
|
|
4
|
+
import { handleInboundUserMessage } from '../monitor/message-delivery';
|
|
5
|
+
import { sendDeliveryReceipt } from '../monitor/handlers/receipts';
|
|
6
|
+
import { nativeAnswerEnvelope } from './observations';
|
|
7
|
+
import { readQuestion, saveQuestion, storedQuestions, type QuestionRecord } from './records';
|
|
8
|
+
import { observedQuestions } from './source';
|
|
9
|
+
export { codexQuestionText } from './observations';
|
|
10
|
+
|
|
11
|
+
const RETENTION_MS = 24 * 60 * 60_000;
|
|
12
|
+
|
|
13
|
+
/** Phone questions travel as device-encrypted control events, independently of task-key grants. */
|
|
14
|
+
export async function publishCodexQuestions(client: RelayClient, sessions: SessionInfo[],
|
|
15
|
+
options: { replay?: boolean; peerPublicKey?: string } = {}): Promise<void> {
|
|
16
|
+
for (const session of sessions.filter(s => s.agent === 'codex')) {
|
|
17
|
+
const path = nativeTranscriptSource(session);
|
|
18
|
+
if (!path) continue;
|
|
19
|
+
let observed;
|
|
20
|
+
try { observed = observedQuestions(session.sessionId, path); }
|
|
21
|
+
catch { continue; }
|
|
22
|
+
for (const question of observed.questions) {
|
|
23
|
+
if (Date.now() - question.createdAt > RETENTION_MS) continue;
|
|
24
|
+
const existing = readQuestion(question.requestId);
|
|
25
|
+
if (!existing) saveQuestion({ ...question, room: client.room });
|
|
26
|
+
}
|
|
27
|
+
const records = storedQuestions().filter(q => q.room === client.room && q.sessionId === session.sessionId)
|
|
28
|
+
.sort((a, b) => a.createdAt - b.createdAt || a.index - b.index);
|
|
29
|
+
for (const record of records) {
|
|
30
|
+
const expired = Date.now() - record.createdAt > RETENTION_MS;
|
|
31
|
+
if (observed.answered.has(record.requestId) && !record.answered) {
|
|
32
|
+
record.answered = true;
|
|
33
|
+
saveQuestion(record);
|
|
34
|
+
}
|
|
35
|
+
if (record.answered || expired) {
|
|
36
|
+
if (!record.resolvedSent || options.replay) {
|
|
37
|
+
await resolveQuestion(client, record, options.peerPublicKey, expired);
|
|
38
|
+
saveQuestion({ ...record, resolvedSent: true });
|
|
39
|
+
}
|
|
40
|
+
} else if (!record.announced || options.replay) {
|
|
41
|
+
const text = [record.question, ...record.options.map(value => '• ' + value)].join('\n');
|
|
42
|
+
await client.send({ type: 'agent.event', kind: 'question', requestId: record.requestId,
|
|
43
|
+
sessionId: session.sessionId, agent: 'codex', projectId: session.projectId,
|
|
44
|
+
taskId: session.taskId, computerId: session.computerId, text, createdAt: record.createdAt },
|
|
45
|
+
'phone', { ttlMs: Math.max(1, record.createdAt + RETENTION_MS - Date.now()),
|
|
46
|
+
wake: !record.announced, peerPublicKey: options.peerPublicKey,
|
|
47
|
+
deliveryId: 'native-question-' + record.requestId });
|
|
48
|
+
saveQuestion({ ...record, announced: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reuse ordinary scoped delivery, policy and receipts; only the human's explicit answer is wrapped. */
|
|
55
|
+
export async function handleCodexQuestionReply(client: RelayClient, message: UserMessage,
|
|
56
|
+
publish: () => Promise<void>, peerPublicKey?: string,
|
|
57
|
+
deliver = handleInboundUserMessage): Promise<boolean> {
|
|
58
|
+
if (!message.requestId || !peerPublicKey) return false;
|
|
59
|
+
const record = readQuestion(message.requestId);
|
|
60
|
+
if (!record || record.room !== client.room || record.sessionId !== message.sessionId) return false;
|
|
61
|
+
const path = nativeTranscriptSource({ sessionId: record.sessionId, agent: 'codex' });
|
|
62
|
+
if (!path) return false;
|
|
63
|
+
try {
|
|
64
|
+
if (observedQuestions(record.sessionId, path).answered.has(record.requestId)) {
|
|
65
|
+
record.answered = true;
|
|
66
|
+
saveQuestion(record);
|
|
67
|
+
}
|
|
68
|
+
} catch { return false; }
|
|
69
|
+
if (record.answered || Date.now() - record.createdAt > RETENTION_MS) {
|
|
70
|
+
if (message.messageId) await sendDeliveryReceipt(client, message.messageId, 'rejected',
|
|
71
|
+
record.answered ? 'This question was already answered in Codex.' : 'This question has expired. Open Codex.',
|
|
72
|
+
record.sessionId, peerPublicKey);
|
|
73
|
+
await resolveQuestion(client, record, peerPublicKey, !record.answered);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (!message.text.trim() || message.text.length > 4_000 || message.attachments?.length
|
|
77
|
+
|| message.attachmentRefs?.length) return false;
|
|
78
|
+
return deliver(client, { ...message, requestId: undefined,
|
|
79
|
+
messageId: message.messageId ?? 'codex-answer-' + record.requestId,
|
|
80
|
+
text: nativeAnswerEnvelope(record, message.text) }, publish, peerPublicKey,
|
|
81
|
+
{ messageId: record.requestId, nativeOnly: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveQuestion(client: RelayClient, record: QuestionRecord, peerPublicKey?: string, expired = false) {
|
|
85
|
+
return client.send({ type: 'approval.resolved', requestId: record.requestId,
|
|
86
|
+
sessionId: record.sessionId, status: expired ? 'expired' : 'applied',
|
|
87
|
+
nativeUiCleared: record.answered === true, resolvedAt: Date.now() },
|
|
88
|
+
'phone', { ttlMs: 15 * 60_000, wake: false, peerPublicKey });
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { safeParse, ts } from '../sessions/support/common';
|
|
4
|
+
|
|
5
|
+
const Questions = z.object({ questions: z.array(z.object({ title: z.string().trim().min(1).max(2_000),
|
|
6
|
+
options: z.array(z.string().trim().min(1).max(512)).max(8).nullish() })).min(1).max(4) });
|
|
7
|
+
const Answer = z.object({ questionItemId: z.string(), answer: z.string().trim().min(1) });
|
|
8
|
+
|
|
9
|
+
export type NativeQuestion = { requestId: string; sessionId: string; callId: string; index: number;
|
|
10
|
+
question: string; options: string[]; createdAt: number };
|
|
11
|
+
|
|
12
|
+
export function questionId(sessionId: string, callId: string, index: number): string {
|
|
13
|
+
return 'codex-question-' + createHash('sha256').update(JSON.stringify([sessionId, callId, index]))
|
|
14
|
+
.digest('hex').slice(0, 32);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nativeQuestionObservations(sessionId: string, lines: string[]) {
|
|
18
|
+
const questions: NativeQuestion[] = [];
|
|
19
|
+
const answered = new Set<string>();
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const row = safeParse(line);
|
|
22
|
+
const p = row?.payload;
|
|
23
|
+
if (!p) continue;
|
|
24
|
+
if (row.type === 'response_item' && p.type === 'function_call' && p.name === 'request_user_input_async'
|
|
25
|
+
&& typeof p.call_id === 'string' && /^[a-zA-Z0-9_-]{1,180}$/.test(p.call_id)) {
|
|
26
|
+
const data = Questions.safeParse(typeof p.arguments === 'string' ? safeParse(p.arguments) : p.arguments);
|
|
27
|
+
const at = ts(row.timestamp);
|
|
28
|
+
if (!data.success || !at) continue;
|
|
29
|
+
data.data.questions.forEach((q, index) => questions.push({ requestId: questionId(sessionId, p.call_id, index),
|
|
30
|
+
sessionId, callId: p.call_id, index, question: q.title, options: q.options ?? [], createdAt: at }));
|
|
31
|
+
}
|
|
32
|
+
const texts = row.type === 'response_item' && p.type === 'message' && p.role === 'user'
|
|
33
|
+
? (p.content ?? []).filter((b: any) => ['input_text', 'text'].includes(b.type)).map((b: any) => b.text)
|
|
34
|
+
: row.type === 'event_msg' && p.type === 'user_message' ? [p.message ?? p.text] : [];
|
|
35
|
+
for (const text of texts) {
|
|
36
|
+
if (typeof text !== 'string') continue;
|
|
37
|
+
const envelope = /^<send_user_message_question_reply>\s*([\s\S]*?)\s*<\/send_user_message_question_reply>$/.exec(text.trim());
|
|
38
|
+
if (!envelope) continue;
|
|
39
|
+
const replies = z.array(Answer).safeParse(safeParse(envelope[1]!));
|
|
40
|
+
if (!replies.success) continue;
|
|
41
|
+
for (const reply of replies.data) {
|
|
42
|
+
const id = z.tuple([z.literal('request_user_input_async'), z.string(), z.number().int().min(0).max(3)])
|
|
43
|
+
.safeParse(safeParse(reply.questionItemId));
|
|
44
|
+
if (id.success) answered.add(questionId(sessionId, id.data[1], id.data[2]));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { questions, answered };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function nativeAnswerEnvelope(question: NativeQuestion, answer: string): string {
|
|
52
|
+
return '<send_user_message_question_reply>\n' + JSON.stringify([{
|
|
53
|
+
questionItemId: JSON.stringify(['request_user_input_async', question.callId, question.index]),
|
|
54
|
+
question: question.question, answer,
|
|
55
|
+
}]) + '\n</send_user_message_question_reply>';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function codexQuestionText(input: unknown): string | undefined {
|
|
59
|
+
const parsed = Questions.safeParse(input);
|
|
60
|
+
if (!parsed.success) return undefined;
|
|
61
|
+
return parsed.data.questions.map(q => [q.title, ...(q.options ?? []).map(option => '• ' + option)]
|
|
62
|
+
.join('\n')).join('\n\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { configDir, writePrivateFile } from '../config';
|
|
5
|
+
import { questionId } from './observations';
|
|
6
|
+
|
|
7
|
+
const Record = z.object({ requestId: z.string(), sessionId: z.string(), callId: z.string(),
|
|
8
|
+
index: z.number().int().min(0).max(3), question: z.string().max(2_000),
|
|
9
|
+
options: z.array(z.string().max(512)).max(8), createdAt: z.number(), room: z.string(),
|
|
10
|
+
announced: z.boolean().optional(), answered: z.boolean().optional(), resolvedSent: z.boolean().optional() });
|
|
11
|
+
export type QuestionRecord = z.infer<typeof Record>;
|
|
12
|
+
const directory = () => join(configDir(), 'codex-question-records');
|
|
13
|
+
|
|
14
|
+
export function readQuestion(requestId: string): QuestionRecord | undefined {
|
|
15
|
+
if (!/^codex-question-[a-f0-9]{32}$/.test(requestId)) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const path = join(directory(), requestId + '.json');
|
|
18
|
+
if (statSync(path).size > 16_384) return undefined;
|
|
19
|
+
const record = Record.parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
20
|
+
return record.requestId === requestId && questionId(record.sessionId, record.callId, record.index) === requestId
|
|
21
|
+
? record : undefined;
|
|
22
|
+
} catch { return undefined; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveQuestion(record: QuestionRecord): void {
|
|
26
|
+
if (!/^codex-question-[a-f0-9]{32}$/.test(record.requestId)) throw new Error('Invalid native question identity');
|
|
27
|
+
writePrivateFile(join(directory(), record.requestId + '.json'), JSON.stringify(record));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function storedQuestions(): QuestionRecord[] {
|
|
31
|
+
if (!existsSync(directory())) return [];
|
|
32
|
+
return readdirSync(directory()).filter(name => /^codex-question-[a-f0-9]{32}\.json$/.test(name))
|
|
33
|
+
.slice(-300).flatMap(name => {
|
|
34
|
+
const record = readQuestion(name.slice(0, -5));
|
|
35
|
+
return record ? [record] : [];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { readCodexLogWindow } from '../sessions/scan/codex';
|
|
3
|
+
import { nativeQuestionObservations } from './observations';
|
|
4
|
+
|
|
5
|
+
type Cached = { revision: string; observation: ReturnType<typeof nativeQuestionObservations> };
|
|
6
|
+
const sources = new Map<string, Cached>();
|
|
7
|
+
|
|
8
|
+
/** Read only a discovered source's bounded tail; idle conversations cost one stat. */
|
|
9
|
+
export function observedQuestions(sessionId: string, path: string) {
|
|
10
|
+
const stat = statSync(path);
|
|
11
|
+
const key = JSON.stringify([sessionId, path]);
|
|
12
|
+
const revision = `${stat.mtimeMs}:${stat.size}`;
|
|
13
|
+
const cached = sources.get(key);
|
|
14
|
+
if (cached?.revision === revision) return cached.observation;
|
|
15
|
+
const observation = nativeQuestionObservations(sessionId, readCodexLogWindow(path, 0, 2 * 1024 * 1024));
|
|
16
|
+
if (sources.size >= 64) sources.clear();
|
|
17
|
+
sources.set(key, { revision, observation });
|
|
18
|
+
return observation;
|
|
19
|
+
}
|
|
@@ -45,6 +45,8 @@ import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../con
|
|
|
45
45
|
import { acceptPhoneAccountLink } from "../../account-recovery/phone-link";
|
|
46
46
|
import { approvalsStatus } from '../../approvals/state';
|
|
47
47
|
import { handleDeliveryReceiptQuery } from '../message-delivery/status-query';
|
|
48
|
+
import { handleCodexQuestionReply, publishCodexQuestions } from '../../codex-questions';
|
|
49
|
+
import { publishedCatalog } from '../../session-catalog';
|
|
48
50
|
|
|
49
51
|
export async function handleMonitorMessage(
|
|
50
52
|
client: RelayClient,
|
|
@@ -70,7 +72,7 @@ export async function handleMonitorMessage(
|
|
|
70
72
|
return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
|
|
71
73
|
}
|
|
72
74
|
if (payload.type === "user.message") {
|
|
73
|
-
return
|
|
75
|
+
return handlePhoneUserMessage(client, payload, publish, input.peerPublicKey);
|
|
74
76
|
} else if (payload.type === 'delivery.receipt.query') {
|
|
75
77
|
return handleDeliveryReceiptQuery(client, payload, input.peerPublicKey);
|
|
76
78
|
} else if (payload.type === "user.attachment") {
|
|
@@ -145,6 +147,13 @@ export async function handleMonitorMessage(
|
|
|
145
147
|
return false;
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
function handlePhoneUserMessage(client: RelayClient, payload: Extract<Payload, { type: 'user.message' }>,
|
|
151
|
+
publish: (forceHistory?: boolean) => Promise<void>, peerPublicKey?: string) {
|
|
152
|
+
return payload.requestId?.startsWith('codex-question-')
|
|
153
|
+
? handleCodexQuestionReply(client, payload, () => publish(), peerPublicKey)
|
|
154
|
+
: handleInboundUserMessage(client, payload, publish, peerPublicKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
148
157
|
function handlePeerConfiguration(client: RelayClient, payload: Payload, peerPublicKey: string | undefined,
|
|
149
158
|
publish: (forceHistory?: boolean) => Promise<void>): Promise<boolean> | undefined {
|
|
150
159
|
if (payload.type === 'project.transport.set') {
|
|
@@ -192,6 +201,9 @@ async function handleInboundRefresh(
|
|
|
192
201
|
// Notification actions must not wait behind provider scans or graph analysis.
|
|
193
202
|
await client.send(approvalsStatus(), 'phone', { peerPublicKey, reliable: false, ttlMs: 90_000 })
|
|
194
203
|
.catch(() => {});
|
|
204
|
+
const known = publishedCatalog(client);
|
|
205
|
+
if (known) await publishCodexQuestions(client, [...known.sessions, ...(known.history ?? [])],
|
|
206
|
+
{ replay: true, peerPublicKey }).catch(() => {});
|
|
195
207
|
// An explicit refresh may pay for the full history scan; routine Mesh
|
|
196
208
|
// snapshots use the cached history and fresh live sessions.
|
|
197
209
|
invalidateMeshSessionHistory();
|
|
@@ -120,6 +120,7 @@ export async function handleUserMessage(
|
|
|
120
120
|
message: UserMessage,
|
|
121
121
|
onProcessing?: () => Promise<void>,
|
|
122
122
|
peerPublicKey?: string,
|
|
123
|
+
native?: { messageId: string; nativeOnly: true },
|
|
123
124
|
): Promise<"accepted" | "rejected"> {
|
|
124
125
|
const say = (text: string, sessionId?: string, wake = false, kind: "status" | "response" = "response") => {
|
|
125
126
|
const payload = agentEventForUserMessage(message, text, sessionId, kind);
|
|
@@ -172,7 +173,8 @@ export async function handleUserMessage(
|
|
|
172
173
|
await onProcessing?.();
|
|
173
174
|
const startedAt = Date.now();
|
|
174
175
|
const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
|
|
175
|
-
messageId: message.messageId,
|
|
176
|
+
messageId: native?.messageId ?? message.messageId,
|
|
177
|
+
nativeOnly: native?.nativeOnly,
|
|
176
178
|
onAgentActivity: () => reportMessageAgentActivity(client, message, peerPublicKey),
|
|
177
179
|
preferredMcp: message.preferredMcp,
|
|
178
180
|
skill: message.skill,
|
|
@@ -10,6 +10,7 @@ export async function handleInboundUserMessage(
|
|
|
10
10
|
client: RelayClient, payload: Extract<Payload, { type: "user.message" }>,
|
|
11
11
|
publish: (forceHistory?: boolean) => Promise<void>,
|
|
12
12
|
peerPublicKey?: string,
|
|
13
|
+
native?: { messageId: string; nativeOnly: true },
|
|
13
14
|
): Promise<boolean> {
|
|
14
15
|
if (payload.requestId) return false;
|
|
15
16
|
let deliveryStarted = false;
|
|
@@ -33,7 +34,7 @@ export async function handleInboundUserMessage(
|
|
|
33
34
|
if (!payload.messageId) return;
|
|
34
35
|
acknowledgeProcessingDelivery(payload.messageId);
|
|
35
36
|
await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId, peerPublicKey);
|
|
36
|
-
}, peerPublicKey);
|
|
37
|
+
}, peerPublicKey, native);
|
|
37
38
|
if (payload.messageId && deliveryStarted) {
|
|
38
39
|
if (outcome === "rejected") abandonDelivery(payload.messageId);
|
|
39
40
|
else {
|
|
@@ -28,6 +28,7 @@ import { boundedCatalogHistory, INTERVAL_MS, publishSessionEvents } from "../han
|
|
|
28
28
|
import { sweepAttachments } from "../handlers/receipts";
|
|
29
29
|
import { handleMonitorMessage } from "../handlers/inbound";
|
|
30
30
|
import { decorateTaskOriginCatalog } from '../../task-origins';
|
|
31
|
+
import { publishCodexQuestions } from '../../codex-questions';
|
|
31
32
|
|
|
32
33
|
const HISTORY_INTERVAL_MS = 60_000;
|
|
33
34
|
const resumedHeads = new WeakSet<RelayClient>();
|
|
@@ -215,6 +216,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
215
216
|
if (leadership.acquire()) {
|
|
216
217
|
await client.send(approvalsStatus(), 'phone', { peerPublicKey, reliable: false, ttlMs: 90_000 })
|
|
217
218
|
.catch(() => {});
|
|
219
|
+
await replayNativeQuestions(client, peerPublicKey);
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
@@ -237,6 +239,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
237
239
|
immediate: true,
|
|
238
240
|
publish: () => publishHeartbeat(client).catch(() => {}),
|
|
239
241
|
});
|
|
242
|
+
const stopQuestions = startQuestionLoop(client, leadership, subscriptions);
|
|
240
243
|
|
|
241
244
|
return {
|
|
242
245
|
publish: () => publish(false),
|
|
@@ -249,6 +252,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
249
252
|
stopMonitorCapabilities(client);
|
|
250
253
|
stopMonitorMeshes(client);
|
|
251
254
|
stopHeartbeat();
|
|
255
|
+
stopQuestions();
|
|
252
256
|
loadLoop.stop();
|
|
253
257
|
off();
|
|
254
258
|
subscriptions.clear();
|
|
@@ -256,3 +260,22 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
256
260
|
},
|
|
257
261
|
};
|
|
258
262
|
}
|
|
263
|
+
|
|
264
|
+
function startQuestionLoop(client: RelayClient, leadership: { acquire: () => boolean }, subscriptions: Set<string>) {
|
|
265
|
+
return startPublishLoop({
|
|
266
|
+
connected: () => client.isConnected,
|
|
267
|
+
intervalMs: 2_000,
|
|
268
|
+
immediate: true,
|
|
269
|
+
publish: async () => {
|
|
270
|
+
if (!leadership.acquire()) return;
|
|
271
|
+
const known = publishedCatalog(client);
|
|
272
|
+
if (known) await publishCodexQuestions(client, [...known.sessions,
|
|
273
|
+
...(known.history ?? []).filter(s => subscriptions.has(s.sessionId))]).catch(() => {});
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function replayNativeQuestions(client: RelayClient, peerPublicKey: string) {
|
|
279
|
+
const known = publishedCatalog(client);
|
|
280
|
+
if (known) await publishCodexQuestions(client, known.sessions, { replay: true, peerPublicKey }).catch(() => {});
|
|
281
|
+
}
|
|
@@ -7,6 +7,8 @@ export type DeliveryOptions = {
|
|
|
7
7
|
onAgentActivity?: (nativeSessionId: string) => void;
|
|
8
8
|
/** Stable controller delivery ID, also used by the provider's native admission. */
|
|
9
9
|
messageId?: string;
|
|
10
|
+
/** Native host questions must never fall back to a second CLI conversation. */
|
|
11
|
+
nativeOnly?: boolean;
|
|
10
12
|
/** Computer-generated identity before temporary attachment paths are inserted. */
|
|
11
13
|
intentDigest?: string;
|
|
12
14
|
preferredMcp?: string;
|
|
@@ -54,6 +54,7 @@ export async function runCodex(
|
|
|
54
54
|
): Promise<ReplyResult> {
|
|
55
55
|
const native = await deliverToCodexDesktop({ session, text, images, options, timeoutMs });
|
|
56
56
|
if (native) return native;
|
|
57
|
+
if (options.nativeOnly) return { ok: false, error: 'Open this conversation in Codex to answer its question.' };
|
|
57
58
|
const configured = loadRuntimeConfig().sessionAccess[session.sessionId];
|
|
58
59
|
const disabledMcp = loadRuntimeConfig().sessionMcpDisabled[session.sessionId] ?? [];
|
|
59
60
|
const sandbox = configured === "read-only"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Host heartbeat presentation
|
|
2
|
+
|
|
3
|
+
Public entry: `heartbeatDisplayText` in `index.ts`. Complete host heartbeat
|
|
4
|
+
instructions and quiet `DONT_NOTIFY` responses do not appear as human messages,
|
|
5
|
+
conversation titles or status previews. `NOTIFY` responses retain their message
|
|
6
|
+
text and Markdown. Native files and IDs are unchanged. Projection happens before
|
|
7
|
+
the live transcript's text limit is applied.
|
|
8
|
+
|
|
9
|
+
Unknown fields, decisions, malformed XML, quoted examples and ordinary messages
|
|
10
|
+
remain visible. XML text accepts only the predefined entities and valid numeric
|
|
11
|
+
character references; it never resolves external resources.
|
|
12
|
+
|
|
13
|
+
Behavioral tests live in `tests/`. License: GrantTap Commercial Source License in
|
|
14
|
+
the repository-root `LICENSE`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Only complete host envelopes are metadata. Quoted or unknown XML stays visible. */
|
|
2
|
+
export function heartbeatDisplayText(value: unknown, kind: string): string | undefined {
|
|
3
|
+
if (typeof value !== 'string' || !['user', 'message', 'final'].includes(kind)) return undefined;
|
|
4
|
+
const root = /^<heartbeat>([\s\S]*)<\/heartbeat>$/.exec(value.trim());
|
|
5
|
+
if (!root) return undefined;
|
|
6
|
+
const body = root[1]!;
|
|
7
|
+
const fields = new Map<string, string>();
|
|
8
|
+
let offset = 0;
|
|
9
|
+
for (const match of body.matchAll(/<([a-z_]+)>([^<]*)<\/\1>/g)) {
|
|
10
|
+
if (body.slice(offset, match.index).trim() || fields.has(match[1]!)) return undefined;
|
|
11
|
+
const decoded = xmlText(match[2]!);
|
|
12
|
+
if (decoded === undefined) return undefined;
|
|
13
|
+
fields.set(match[1]!, decoded.trim());
|
|
14
|
+
offset = match.index + match[0].length;
|
|
15
|
+
}
|
|
16
|
+
if (body.slice(offset).trim() || !fields.get('automation_id')) return undefined;
|
|
17
|
+
if (kind === 'user') {
|
|
18
|
+
if ([...fields.keys()].some(key => !['automation_id', 'current_time_iso', 'instructions'].includes(key))
|
|
19
|
+
|| !fields.get('instructions') || fields.get('current_time_iso') === '') return undefined;
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
if (fields.size !== 3 || !fields.has('decision') || !fields.get('message')) return undefined;
|
|
23
|
+
if (fields.get('decision') === 'DONT_NOTIFY') return '';
|
|
24
|
+
return fields.get('decision') === 'NOTIFY' ? fields.get('message') : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function xmlText(value: string): string | undefined {
|
|
28
|
+
const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
29
|
+
if ([...value].some(character => !xmlCharacter(character.codePointAt(0)!))) return undefined;
|
|
30
|
+
if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);)/.test(value)) return undefined;
|
|
31
|
+
let valid = true;
|
|
32
|
+
const decoded = value.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);/g, (_all, entity: string) => {
|
|
33
|
+
if (entities[entity] !== undefined) return entities[entity];
|
|
34
|
+
const number = entity.startsWith('#x') ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1));
|
|
35
|
+
if (xmlCharacter(number)) {
|
|
36
|
+
return String.fromCodePoint(number);
|
|
37
|
+
}
|
|
38
|
+
valid = false;
|
|
39
|
+
return '';
|
|
40
|
+
});
|
|
41
|
+
return valid ? decoded : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function xmlCharacter(number: number): boolean {
|
|
45
|
+
return number === 9 || number === 10 || number === 13 || number >= 0x20 && number <= 0xd7ff
|
|
46
|
+
|| number >= 0xe000 && number <= 0xfffd || number >= 0x10000 && number <= 0x10ffff;
|
|
47
|
+
}
|
|
@@ -10,7 +10,7 @@ export function nativeTranscriptRoots(): { claude: string; codex: string } {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Native paths come only from discovery; a phone never supplies one. */
|
|
13
|
-
export function nativeTranscriptSource(session: SessionInfo): string | undefined {
|
|
13
|
+
export function nativeTranscriptSource(session: Pick<SessionInfo, 'agent' | 'sessionId'>): string | undefined {
|
|
14
14
|
if (session.agent === "claude") return claudeLogPathBySession.get(session.sessionId);
|
|
15
15
|
if (session.agent === "codex") return codexLogPathBySession.get(session.sessionId);
|
|
16
16
|
return undefined;
|
|
@@ -8,6 +8,7 @@ import { redactSecrets } from "../../../telemetry/command-preview";
|
|
|
8
8
|
import { activityTelemetry, pendingCapabilityObservation, type CapabilityObservation, type PendingCapabilityTool } from "../../../telemetry";
|
|
9
9
|
import type { RecordedFileChange } from "../../../../transcript-history/changes";
|
|
10
10
|
import type { ToolCallDetail } from "../../../../transcript-history/tool-details";
|
|
11
|
+
import { codexQuestionText } from '../../../../codex-questions';
|
|
11
12
|
|
|
12
13
|
function codexToolInput(payload: any): unknown {
|
|
13
14
|
const input = payload.arguments ?? payload.input ?? payload.action;
|
|
@@ -82,6 +83,7 @@ export function appendCodexActivity(input: {
|
|
|
82
83
|
const toolName = String(p.name ?? p.type);
|
|
83
84
|
const callId = String(p.call_id ?? p.id ?? `${createdAt}:${index}`);
|
|
84
85
|
const sourceId = `${sourceThreadId}:${callId}`;
|
|
86
|
+
if (appendQuestionIfPresent(input, toolName, args, createdAt, sourceId)) return;
|
|
85
87
|
const pending: PendingCapabilityTool = {
|
|
86
88
|
sourceId,
|
|
87
89
|
sessionId: session.sessionId,
|
|
@@ -111,3 +113,13 @@ export function appendCodexActivity(input: {
|
|
|
111
113
|
}
|
|
112
114
|
});
|
|
113
115
|
}
|
|
116
|
+
|
|
117
|
+
function appendQuestionIfPresent(input: Parameters<typeof appendCodexActivity>[0], toolName: string,
|
|
118
|
+
args: unknown, createdAt: number, sourceId: string): boolean {
|
|
119
|
+
const question = toolName === 'request_user_input_async' ? codexQuestionText(args) : undefined;
|
|
120
|
+
if (!question) return false;
|
|
121
|
+
appendEntry({ out: input.out, seen: input.seen, sessionId: input.session.sessionId,
|
|
122
|
+
kind: 'message', text: question, createdAt, ordinal: 0, idOverride: sourceId,
|
|
123
|
+
fullText: input.fullText, extras: input.child ? childEntryFields(input.child) : {} });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -16,6 +16,7 @@ import type { SessionInfo } from "../../../../../../packages/protocol/schema";
|
|
|
16
16
|
import { childTitle } from "../../support/child-threads";
|
|
17
17
|
import { visibleUserText } from "../../support/activity-helpers";
|
|
18
18
|
import { codexHeadRequest, isProvisionalPageTitle } from "../../support/codex-head";
|
|
19
|
+
import { heartbeatDisplayText } from '../../host-heartbeat';
|
|
19
20
|
|
|
20
21
|
type CodexParseState = {
|
|
21
22
|
sessionId: string;
|
|
@@ -107,7 +108,9 @@ function applyCodexLine(d: any, state: CodexParseState): void {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
if (d.type === "event_msg" && d.payload?.type === "agent_message") {
|
|
110
|
-
|
|
111
|
+
const raw = d.payload.message ?? d.payload.text;
|
|
112
|
+
const visible = heartbeatDisplayText(raw, 'message') ?? raw;
|
|
113
|
+
if (visible) state.summary = childTitle(visible)?.slice(0, 180);
|
|
111
114
|
}
|
|
112
115
|
if (
|
|
113
116
|
d.type === "response_item" &&
|
|
@@ -117,7 +120,8 @@ function applyCodexLine(d: any, state: CodexParseState): void {
|
|
|
117
120
|
) {
|
|
118
121
|
for (const block of d.payload.content) {
|
|
119
122
|
if (["output_text", "text"].includes(block?.type) && typeof block.text === "string") {
|
|
120
|
-
|
|
123
|
+
const visible = heartbeatDisplayText(block.text, 'message') ?? block.text;
|
|
124
|
+
if (visible) state.summary = childTitle(visible)?.slice(0, 180);
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Provider-specific thinking/reasoning blocks are deliberately ignored.
|
|
5
5
|
*/
|
|
6
6
|
import type { ActivityEntry } from "../../../../../packages/protocol/schema";
|
|
7
|
+
import { heartbeatDisplayText } from '../host-heartbeat';
|
|
7
8
|
import {
|
|
8
9
|
parseMcpToolName,
|
|
9
10
|
skillNameFromInput,
|
|
@@ -61,6 +62,8 @@ export function stripAttachmentNote(text: string): string {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function visibleUserText(value: unknown): string {
|
|
65
|
+
const heartbeat = heartbeatDisplayText(value, 'user');
|
|
66
|
+
if (heartbeat !== undefined) return heartbeat;
|
|
64
67
|
// Codex records the selected Page as a user row, including a null selection.
|
|
65
68
|
// Remove the host-owned block, retaining any real request alongside it.
|
|
66
69
|
const contextFree = String(value ?? "").replace(
|
|
@@ -246,7 +249,8 @@ export function pushEntry(input: PushEntryInput): void {
|
|
|
246
249
|
const shownKind: ActivityEntry["kind"] = notification ? "status" : kind;
|
|
247
250
|
const limit = shownKind === "tool" || shownKind === "status" ? MAX_ACTIVITY_TEXT
|
|
248
251
|
: input.fullText ? Infinity : MAX_MESSAGE_TEXT;
|
|
249
|
-
const
|
|
252
|
+
const presented = heartbeatDisplayText(text, kind) ?? text;
|
|
253
|
+
const clean = activityText(notification ?? (kind === "user" ? visibleUserText(text) : presented), limit);
|
|
250
254
|
if (!clean) return;
|
|
251
255
|
const duplicateKey = idOverride ? `${idOverride}:${shownKind}`
|
|
252
256
|
: `${createdAt}:${extras.childThreadId ?? "root"}:${shownKind}:${extras.toolName ?? ""}:${clean}`;
|
|
@@ -8,6 +8,7 @@ import { recordedFileChanges } from "./changes";
|
|
|
8
8
|
import { recordedToolDetails } from "./tool-details";
|
|
9
9
|
import { completedTurnChanges } from "./turn-changes";
|
|
10
10
|
import { cursorActivity, cursorConversationHistory } from "../sessions/cursor";
|
|
11
|
+
import { parseCodexFile } from '../sessions/scan/codex/parse';
|
|
11
12
|
export { nativeImageLines } from "./images";
|
|
12
13
|
export { nativeTranscriptImage } from './images/native';
|
|
13
14
|
export { nativeTranscriptEntry } from './images/advertised-entry';
|
|
@@ -93,7 +94,8 @@ export function readTranscriptHistory(input: SessionInfo, cursor?: string): Sess
|
|
|
93
94
|
: window.lines[0]?.offset ?? window.start;
|
|
94
95
|
const prior = input.sessionId !== session.sessionId && offset === 0 ? transferHistory(input) : undefined;
|
|
95
96
|
return { type: "session.activity", sessionId: input.sessionId, agent: session.agent,
|
|
96
|
-
state: session.
|
|
97
|
+
state: session.agent === 'codex' ? parseCodexFile(path)?.session.state ?? session.state : session.state,
|
|
98
|
+
entries, generatedAt: Date.now(), history: {
|
|
97
99
|
hasMore: offset > 0 || Boolean(prior?.entries.length),
|
|
98
100
|
...(offset > 0 ? { cursor: window.cursorAt(offset) }
|
|
99
101
|
: prior?.entries.length ? { cursor: `transfer-context:${prior.entries.length + (prior.history?.hasMore ? Number(prior.history.cursor?.split(':')[1]) : 0)}` } : {}),
|
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.101
|
|
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.101\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
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.101";
|
|
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
|
@@ -26,3 +26,9 @@ controller, with priority over queued periodic snapshots. Older callers without
|
|
|
26
26
|
a requester retain the complete authorized recipient set. Every explicit target
|
|
27
27
|
passes the pairing and controller authorization checks. Routing never deletes a
|
|
28
28
|
pairing or changes approval policy.
|
|
29
|
+
|
|
30
|
+
Requested image chunks retain their local scheduling priority through task
|
|
31
|
+
encryption and precede background transcript backfill. The local `urgent` send
|
|
32
|
+
hint is never serialized into relay routing metadata and does not request APNs.
|
|
33
|
+
The image-priority test receives and decrypts both layers and verifies ordering
|
|
34
|
+
while preserving every background frame and the existing six-millisecond pace.
|
|
@@ -71,7 +71,7 @@ export class OutboundFrameQueue {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export const priorityControlPayload = (type: Payload["type"]) => type === "hello" || type.startsWith("approval.")
|
|
74
|
+
export const priorityControlPayload = (type: Payload["type"]) => type === "hello" || type === "session.image.chunk" || type.startsWith("approval.")
|
|
75
75
|
|| type.startsWith("config.") || type.startsWith("pairing.") || type === "session.key.grant"
|
|
76
76
|
|| type === "delivery.receipt" || type === "sessions.status" || type === "machine.heartbeat" || type === "approvals.status";
|
|
77
77
|
|
|
@@ -83,6 +83,6 @@ export async function sendEncryptedPayload(queue: OutboundFrameQueue, config: Pe
|
|
|
83
83
|
if (!peers.length) throw new Error("no authorized pairing peers");
|
|
84
84
|
for (const peerPublicKey of peers) {
|
|
85
85
|
await queue.send(sealEnvelope({ ...config, peerPublicKey }, payload, to,
|
|
86
|
-
peers.length === 1 ? options : { ...options, deliveryId: undefined }), priorityControlPayload(payload.type) || options.wake === true);
|
|
86
|
+
peers.length === 1 ? options : { ...options, deliveryId: undefined }), priorityControlPayload(payload.type) || options.urgent === true || options.wake === true);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -46,6 +46,8 @@ export type RelayClientOptions = {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
export type SendOptions = {
|
|
49
|
+
/** Local scheduling only; never serialized into relay routing metadata. */
|
|
50
|
+
urgent?: boolean;
|
|
49
51
|
/** Negotiated opaque latest-state replay, independent of delivery ACKs. */
|
|
50
52
|
retainedState?: boolean;
|
|
51
53
|
stateKey?: string;
|
|
@@ -214,7 +214,7 @@ export class RelayClient {
|
|
|
214
214
|
|| !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
|
|
215
215
|
if (!this.frames) throw new Error("relay not connected");
|
|
216
216
|
await this.frames.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), { ...retainedPayloadOptions(payload), ...options, retainedState: this.retainedState, recipientRouting: this.recipientRouting }),
|
|
217
|
-
priorityControlPayload(payload.type) || options.wake === true);
|
|
217
|
+
priorityControlPayload(payload.type) || options.urgent === true || options.wake === true);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
setSessionKey(sessionId: string, key: string): void {
|
|
@@ -232,7 +232,8 @@ export class RelayClient {
|
|
|
232
232
|
to: Role | "all" = this.otherRole(),
|
|
233
233
|
options: SendOptions = {},
|
|
234
234
|
): Promise<void> {
|
|
235
|
-
await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, { ...retainedPayloadOptions(payload), ...options
|
|
235
|
+
await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, { ...retainedPayloadOptions(payload), ...options,
|
|
236
|
+
urgent: options.urgent === true || priorityControlPayload(payload.type) });
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
/** Register before connect so only durable receivers advertise task-job support. */
|