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.
@@ -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 handleInboundUserMessage(client, payload, publish, input.peerPublicKey);
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
- state.summary = childTitle(d.payload.message ?? d.payload.text)?.slice(0, 180);
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
- state.summary = childTitle(block.text)?.slice(0, 180);
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 clean = activityText(notification ?? (kind === "user" ? visibleUserText(text) : text), limit);
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.state, entries, generatedAt: Date.now(), history: {
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)}` } : {}),
@@ -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.99
13
+ npm install -g granttap-mcp@0.8.101
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.99\";\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.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.99";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.99",
3
+ "version": "0.8.101",
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": {
@@ -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. */