granttap-mcp 0.8.77 → 0.8.78

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 CHANGED
@@ -29,6 +29,12 @@ requesting controller, and restricted to that native execution's admitted
29
29
  checkout. Received previews remain on the device for offline inspection.
30
30
  **Agents** opens nested execution conversations within the same Task.
31
31
 
32
+ Messages sent to a chat open in Codex Desktop continue that same conversation,
33
+ including while a turn is running. The runtime forwards them to the existing
34
+ desktop owner and preserves the delivery ID across retries. **Pause** reaches
35
+ that owner too. A confirmed receipt means Codex accepted the message; its answer
36
+ then arrives through the existing chat history.
37
+
32
38
  The MCP runtime, plugins, and CLI use the GrantTap Commercial Source License.
33
39
  Product use requires authorized GrantTap account access; official TestFlight
34
40
  access remains free. Earlier releases through 0.8.75 retain their Apache-2.0
@@ -0,0 +1,31 @@
1
+ # Codex desktop delivery
2
+
3
+ Public entry: `deliverToCodexDesktop` in `index.ts`. A paired controller's follow-up
4
+ goes to the native owner of the existing conversation, including between turns.
5
+ Starting a second `codex exec resume` against that owner's thread would conflict
6
+ with its single writer. New Tasks continue to use the provider's CLI creation path.
7
+
8
+ The local desktop coordination protocol is versioned and differs from the public
9
+ app-server WebSocket transport. This adapter implements its framing independently;
10
+ no desktop application source is bundled. The existing socket must be private,
11
+ owned by the current user, and inside the configured Codex home. Nothing creates,
12
+ replaces, or restarts the native router. Windows has no tested desktop socket path
13
+ and keeps the CLI path.
14
+
15
+ The native owner inherits its workspace and approval settings. Explicit per-Task
16
+ sandbox settings and model/effort are carried with the turn. Disabled MCP settings
17
+ that cannot be enforced through the owner are refused. Protocol errors or an
18
+ unconfirmed submission never fall back to a second writer. Only a missing socket
19
+ or an explicit owner-not-found response permits headless resume.
20
+
21
+ The original message ID is carried into Codex. A private, cross-process receipt
22
+ records a hash of the intent before dispatch and native turn ID after acceptance.
23
+ An uncertain or crashed dispatch is not replayed. A native receipt produces no
24
+ synthetic assistant reply or completed-run digest; the existing transcript carries
25
+ the actual work. Pause sends the user's stop request to the same owner. Document
26
+ attachments remain in private staging for up to a day after admission; the monitor
27
+ removes expired stages. Image-only stages are removed once native admission has
28
+ consumed them. Tests exercise native delivery, framing, socket trust, durable
29
+ deduplication, and refusal without launching the CLI.
30
+
31
+ License: GrantTap Commercial Source License in the repository-root `LICENSE`.
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { connect, type Socket } from 'node:net';
3
+
4
+ type Response = { type: 'response'; requestId: string; method?: string; resultType: string;
5
+ handledByClientId?: string; result?: any; error?: string };
6
+ type Pending = { method: string; target?: string; resolve: (response: Response) => void;
7
+ reject: (error: Error) => void; timer: NodeJS.Timeout };
8
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
9
+
10
+ /** The desktop coordination socket is length-prefixed JSON, not another app-server. */
11
+ export class DesktopConnection {
12
+ private readonly socket: Socket;
13
+ private readonly pending = new Map<string, Pending>();
14
+ private buffered = Buffer.alloc(0);
15
+ private clientId = 'initializing-client';
16
+ private closed = false;
17
+ private readonly deadline: number;
18
+
19
+ constructor(path: string, timeoutMs: number) {
20
+ this.deadline = Date.now() + Math.min(5_000, timeoutMs);
21
+ this.socket = connect(path);
22
+ this.socket.on('error', () => this.close());
23
+ this.socket.on('close', () => this.close());
24
+ this.socket.on('data', chunk => {
25
+ try { this.receive(chunk); } catch { this.close(); }
26
+ });
27
+ }
28
+
29
+ async initialize(): Promise<void> {
30
+ const response = await this.call('initialize', { clientType: 'desktop' }, 0);
31
+ const id = response.result?.clientId;
32
+ if (response.resultType !== 'success' || typeof id !== 'string' || !id || id.length > 180) {
33
+ throw new Error('Codex desktop initialization is unavailable.');
34
+ }
35
+ this.clientId = id;
36
+ }
37
+
38
+ call(method: string, params: unknown, version: number, target?: string): Promise<Response> {
39
+ return new Promise((resolve, reject) => {
40
+ if (this.closed) return reject(new Error('Codex desktop disconnected.'));
41
+ const requestId = randomUUID();
42
+ const timer = setTimeout(() => {
43
+ this.pending.delete(requestId);
44
+ reject(new Error('Codex desktop has not confirmed the request.'));
45
+ }, Math.max(1, this.deadline - Date.now()));
46
+ this.pending.set(requestId, { method, target, resolve, reject, timer });
47
+ this.send({ type: 'request', requestId, sourceClientId: this.clientId,
48
+ method, params, version, ...(target ? { targetClientId: target } : {}) });
49
+ });
50
+ }
51
+
52
+ close(): void {
53
+ if (this.closed) return;
54
+ this.closed = true;
55
+ this.socket.destroy();
56
+ this.buffered = Buffer.alloc(0);
57
+ for (const request of this.pending.values()) {
58
+ clearTimeout(request.timer);
59
+ request.reject(new Error('Codex desktop disconnected.'));
60
+ }
61
+ this.pending.clear();
62
+ }
63
+
64
+ private send(value: unknown): void {
65
+ const body = Buffer.from(JSON.stringify(value));
66
+ if (body.length > MAX_FRAME_BYTES) return this.close();
67
+ const prefix = Buffer.alloc(4);
68
+ prefix.writeUInt32LE(body.length);
69
+ this.socket.write(Buffer.concat([prefix, body]));
70
+ }
71
+
72
+ private receive(chunk: Buffer): void {
73
+ this.buffered = Buffer.concat([this.buffered, chunk]);
74
+ while (this.buffered.length >= 4) {
75
+ const size = this.buffered.readUInt32LE();
76
+ if (size === 0 || size > MAX_FRAME_BYTES) throw new Error('Invalid desktop frame.');
77
+ if (this.buffered.length < size + 4) return;
78
+ const message = JSON.parse(this.buffered.subarray(4, size + 4).toString());
79
+ this.buffered = this.buffered.subarray(size + 4);
80
+ this.handle(message);
81
+ }
82
+ }
83
+
84
+ private handle(message: any): void {
85
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return;
86
+ if (message.type === 'client-discovery-request') {
87
+ this.send({ type: 'client-discovery-response', requestId: message.requestId,
88
+ response: { canHandle: false } });
89
+ } else if (message.type === 'request') {
90
+ this.send({ type: 'response', requestId: message.requestId,
91
+ resultType: 'error', error: 'no-handler-for-request' });
92
+ } else if (message.type === 'response') {
93
+ const request = this.pending.get(message.requestId);
94
+ if (!request) return;
95
+ if (message.method !== undefined && message.method !== request.method
96
+ || message.resultType === 'success' && request.target && message.handledByClientId !== request.target) {
97
+ return this.close();
98
+ }
99
+ this.pending.delete(message.requestId);
100
+ clearTimeout(request.timer);
101
+ request.resolve(message);
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,21 @@
1
+ import { DesktopConnection } from './connection';
2
+ import { desktopSocketPath } from './socket-path';
3
+
4
+ /** A deliberate user stop reaches the owning desktop; no provider process is killed. */
5
+ export async function interruptCodexDesktop(sessionId: string): Promise<{ ok: boolean } | undefined> {
6
+ let connection: DesktopConnection | undefined;
7
+ try {
8
+ const path = desktopSocketPath();
9
+ if (!path) return undefined;
10
+ connection = new DesktopConnection(path, 2_500);
11
+ await connection.initialize();
12
+ const owner = await connection.call('thread-owner-discovery', { hostId: 'local', conversationId: sessionId }, 1);
13
+ if (owner.resultType === 'error' && owner.error === 'no-client-found') return undefined;
14
+ if (owner.resultType !== 'success' || !owner.handledByClientId) return { ok: false };
15
+ // Version 3 is the native explicit-user-stop form, without a stale expected turn ID.
16
+ const response = await connection.call('thread-follower-interrupt-turn',
17
+ { conversationId: sessionId, mode: 'user-stop', expectedTurnId: null }, 3, owner.handledByClientId);
18
+ return { ok: response.resultType === 'success' && response.result?.ok === true };
19
+ } catch { return { ok: false }; }
20
+ finally { connection?.close(); }
21
+ }
@@ -0,0 +1,78 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import type { SessionInfo } from '../../../../packages/protocol/schema';
4
+ import { loadRuntimeConfig } from '../config';
5
+ import type { DeliveryOptions, ReplyResult } from '../reply';
6
+ import { DesktopConnection } from './connection';
7
+ import { DesktopReceipt } from './receipts';
8
+ import { desktopSocketPath } from './socket-path';
9
+ export { interruptCodexDesktop } from './controls';
10
+
11
+ const UNAVAILABLE = 'Codex desktop is not ready to receive this message. Keep the original chat open and try again.';
12
+ const UNCERTAIN = 'Codex has not confirmed this message. Check the original chat before sending it again.';
13
+
14
+ /** Undefined means no desktop owner exists; only then may the caller resume a CLI. */
15
+ export async function deliverToCodexDesktop(input: {
16
+ session: SessionInfo; text: string; images: string[]; options: DeliveryOptions; timeoutMs: number;
17
+ }): Promise<ReplyResult | undefined> {
18
+ let connection: DesktopConnection | undefined;
19
+ let dispatched = false;
20
+ const { session, text, images, options } = input;
21
+ const accepted: ReplyResult = { ok: true, text: '', sessionId: session.sessionId, delivery: 'native' };
22
+ try {
23
+ const policy = loadRuntimeConfig();
24
+ const request = turnRequest(input, policy.sessionAccess[session.sessionId]);
25
+ const intent = options.intentDigest ?? { text,
26
+ images: images.map(path => createHash('sha256').update(readFileSync(path)).digest('hex')), options };
27
+ const receipt = new DesktopReceipt(session.sessionId, request.clientUserMessageId, intent);
28
+ const prior = receipt.lookup();
29
+ if (prior === 'accepted') return accepted;
30
+ if (prior) return refusal(prior);
31
+ const path = desktopSocketPath();
32
+ if (!path) return undefined;
33
+ connection = new DesktopConnection(path, input.timeoutMs);
34
+ await connection.initialize();
35
+ const owner = await connection.call('thread-owner-discovery',
36
+ { hostId: 'local', conversationId: session.sessionId }, 1);
37
+ if (owner.resultType === 'error' && owner.error === 'no-client-found') return undefined;
38
+ if (owner.resultType !== 'success' || !owner.handledByClientId) return { ok: false, error: UNAVAILABLE };
39
+ if (policy.sessionMcpDisabled[session.sessionId]?.length) {
40
+ return { ok: false, error: 'Apply this Task’s disabled MCP settings in Codex before continuing its open desktop chat.' };
41
+ }
42
+ const state = receipt.begin();
43
+ if (state === 'accepted') return accepted;
44
+ if (state !== 'new') return refusal(state);
45
+ dispatched = true;
46
+ const response = await connection.call('thread-follower-start-turn', {
47
+ conversationId: session.sessionId,
48
+ turnStart: { request, context: { inheritThreadSettings: true } },
49
+ }, 2, owner.handledByClientId);
50
+ const turnId = response.result?.result?.turn?.id;
51
+ if (response.resultType !== 'success' || typeof turnId !== 'string' || !turnId || turnId.length > 180) {
52
+ return { ok: false, error: UNCERTAIN, delivery: 'native' };
53
+ }
54
+ receipt.accept(turnId);
55
+ return accepted;
56
+ } catch {
57
+ return { ok: false, error: dispatched ? UNCERTAIN : UNAVAILABLE,
58
+ ...(dispatched ? { delivery: 'native' as const } : {}) };
59
+ } finally { connection?.close(); }
60
+ }
61
+
62
+ function refusal(state: string): ReplyResult {
63
+ return state === 'conflict' ? { ok: false, error: 'This message ID belongs to a different Codex delivery.' }
64
+ : { ok: false, error: UNCERTAIN, delivery: 'native' };
65
+ }
66
+
67
+ function turnRequest(input: { session: SessionInfo; text: string; images: string[]; options: DeliveryOptions }, access?: string) {
68
+ const { session, text, images, options } = input;
69
+ const effort = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(options.effort ?? '') ? options.effort : undefined;
70
+ const sandboxPolicy = access === 'read-only' ? { type: 'readOnly', networkAccess: false }
71
+ : access === 'workspace' ? { type: 'workspaceWrite', writableRoots: session.cwd ? [session.cwd] : [],
72
+ networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false }
73
+ : access === 'full' ? { type: 'dangerFullAccess' } : undefined;
74
+ return { threadId: session.sessionId, clientUserMessageId: options.messageId ?? randomUUID(),
75
+ input: [{ type: 'text', text, text_elements: [] }, ...images.map(path => ({ type: 'localImage', path }))],
76
+ ...(options.model ? { model: options.model } : {}), ...(effort ? { effort } : {}),
77
+ ...(sandboxPolicy ? { sandboxPolicy } : {}) };
78
+ }
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { withStoreLock } from '../../../../packages/core/store-file-lock';
6
+ import { configDir, writePrivateFile } from '../config';
7
+
8
+ const Receipt = z.object({ digest: z.string().length(64), phase: z.enum(['sending', 'accepted']),
9
+ turnId: z.string().min(1).max(180).optional() }).strict();
10
+ type Record = z.infer<typeof Receipt>;
11
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex');
12
+
13
+ /** A crashed or disconnected submission is never replayed as a new native turn. */
14
+ export class DesktopReceipt {
15
+ private readonly path: string;
16
+ private readonly digest: string;
17
+
18
+ constructor(threadId: string, messageId: string, intent: unknown) {
19
+ this.path = join(configDir(), 'codex-desktop-deliveries', hash(`${threadId}:${messageId}`) + '.json');
20
+ this.digest = hash(JSON.stringify(intent));
21
+ }
22
+
23
+ begin(): 'new' | 'accepted' | 'uncertain' | 'conflict' {
24
+ return withStoreLock(this.path, () => {
25
+ const prior = this.lookup();
26
+ if (prior) return prior;
27
+ this.save({ digest: this.digest, phase: 'sending' });
28
+ return 'new';
29
+ });
30
+ }
31
+
32
+ lookup(): 'accepted' | 'uncertain' | 'conflict' | undefined {
33
+ if (!existsSync(this.path)) return undefined;
34
+ const prior = this.read();
35
+ if (prior.digest !== this.digest) return 'conflict';
36
+ return prior.phase === 'accepted' ? 'accepted' : 'uncertain';
37
+ }
38
+
39
+ accept(turnId: string): void {
40
+ withStoreLock(this.path, () => {
41
+ const prior = this.read();
42
+ if (prior.digest !== this.digest) throw new Error('Codex delivery identity changed.');
43
+ this.save({ ...prior, phase: 'accepted', turnId });
44
+ });
45
+ }
46
+
47
+ private read(): Record { return Receipt.parse(JSON.parse(readFileSync(this.path, 'utf8'))); }
48
+ private save(record: Record): void {
49
+ writePrivateFile(this.path, JSON.stringify(record) + '\n');
50
+ for (const path of [this.path, ...(process.platform === 'win32' ? [] : [dirname(this.path)])]) {
51
+ const fd = openSync(path, 'r');
52
+ try { fsyncSync(fd); } finally { closeSync(fd); }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,25 @@
1
+ import { lstatSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ /** Only the user's existing desktop router may receive a paired controller's input. */
6
+ export function desktopSocketPath(): string | undefined {
7
+ if (process.platform === 'win32') return undefined;
8
+ const root = process.env.GRANTTAP_CODEX_DIR ?? process.env.NODVOX_CODEX_DIR
9
+ ?? process.env.CODEX_HOME ?? join(homedir(), '.codex');
10
+ const directory = join(root, 'ipc');
11
+ const socket = join(directory, 'ipc.sock');
12
+ for (const [path, isSocket] of [[root, false], [directory, false], [socket, true]] as const) {
13
+ try {
14
+ const stat = lstatSync(path);
15
+ if (stat.uid !== process.getuid?.() || (stat.mode & 0o022) !== 0
16
+ || (isSocket ? !stat.isSocket() || (stat.mode & 0o077) !== 0 : !stat.isDirectory())) {
17
+ throw new Error('The Codex desktop connection is not private.');
18
+ }
19
+ } catch (error) {
20
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
21
+ throw error;
22
+ }
23
+ }
24
+ return socket;
25
+ }
@@ -3,6 +3,7 @@ import { MAX_USER_ATTACHMENTS } from "../../../../../packages/protocol/schema";
3
3
  import type { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import { pruneAttachments, takeAttachment } from "../../delivery/attachment-store";
5
5
  import { sendSessionPayload } from "../../host/session-keys";
6
+ import { prunePreparedAttachments } from '../../reply';
6
7
 
7
8
  export async function sendDeliveryReceipt(
8
9
  client: RelayClient,
@@ -54,7 +55,7 @@ let attachmentsSweptAt = 0;
54
55
  export function sweepAttachments(now = Date.now()): number {
55
56
  if (now - attachmentsSweptAt < ATTACHMENT_SWEEP_MS) return 0;
56
57
  attachmentsSweptAt = now;
57
- return pruneAttachments(now);
58
+ return pruneAttachments(now) + prunePreparedAttachments(now);
58
59
  }
59
60
 
60
61
  /**
@@ -171,6 +171,7 @@ export async function handleUserMessage(
171
171
  await onProcessing?.();
172
172
  const startedAt = Date.now();
173
173
  const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
174
+ messageId: message.messageId,
174
175
  preferredMcp: message.preferredMcp,
175
176
  skill: message.skill,
176
177
  model: message.model,
@@ -178,6 +179,7 @@ export async function handleUserMessage(
178
179
  effort: message.effort,
179
180
  });
180
181
  const settle = async (result: ReplyResult, late: boolean) => {
182
+ if (result.ok && result.delivery === 'native') return;
181
183
  // The run's turns are in the transcript, but a session holding this chat
182
184
  // open never sees them; the journal is how it finds out on its next prompt,
183
185
  // and the Task carries the same digest as progress.
@@ -30,3 +30,9 @@ Explicit Cursor task turns pass `--trust` for the selected workspace so an
30
30
  updated CLI cannot wait for an invisible desktop trust prompt. Resumes without
31
31
  a reported workspace are refused. Tool approvals and global action bans remain
32
32
  active; `--force`, `--yolo` and automatic MCP approval are never added.
33
+
34
+ An existing Codex desktop conversation receives follow-ups through its native
35
+ owner, without a competing `exec resume` writer. The `codex-desktop` feature owns
36
+ that versioned local connection and durable admission receipt. Headless resume is
37
+ used only when no native owner exists. Native admission carries the controller's
38
+ message ID and returns promptly; the provider transcript supplies the answer.
@@ -13,6 +13,7 @@ import { withAttachments } from "./payload/attachments";
13
13
  import { abortProcesses, runProcess } from "./process";
14
14
  import { routingPrompt } from "./payload/routing";
15
15
  import type { DeliveryOptions, ReplyResult } from "./payload/types";
16
+ import { deliveryIntent } from './payload/identity';
16
17
  import {
17
18
  runCursorNew,
18
19
  runCursorResume,
@@ -20,6 +21,7 @@ import {
20
21
  runGrokResume,
21
22
  } from "./process/provider-headless";
22
23
  export type { DeliveryOptions, ReplyResult } from "./payload/types";
24
+ export { prunePreparedAttachments } from './payload/attachments';
23
25
 
24
26
  export const GENERAL_WORKSPACE = "granttap:general";
25
27
 
@@ -59,6 +61,7 @@ export function deliverToSession(
59
61
  if (!options.ignorePause && isSessionPaused(session.sessionId)) {
60
62
  return Promise.resolve({ ok: false, error: PAUSED_SESSION_REASON });
61
63
  }
64
+ if (session.agent === 'codex') options = { ...options, intentDigest: deliveryIntent(text, attachments, options) };
62
65
  if (/^transfer-[a-f0-9]{48}$/.test(session.sessionId)) {
63
66
  return queued(session.sessionId, async () => (await localTaskTransfers().deliver(
64
67
  session.sessionId, text, attachments, options, {
@@ -1,8 +1,9 @@
1
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs';
3
3
  import { basename, join } from "node:path";
4
4
  import { MAX_USER_ATTACHMENTS, type UserAttachment } from "../../../../../packages/protocol/schema";
5
5
  import type { ReplyResult } from "./types";
6
+ import { configDir } from '../../config';
6
7
 
7
8
  type PreparedAttachments = { prompt: string; claudePrompt: string; images: string[] };
8
9
 
@@ -12,24 +13,47 @@ export async function withAttachments(
12
13
  run: (prepared: PreparedAttachments) => Promise<ReplyResult>,
13
14
  ): Promise<ReplyResult> {
14
15
  if (attachments.length === 0) return run({ prompt: text, claudePrompt: text, images: [] });
15
- const directory = await mkdtemp(join(tmpdir(), "granttap-attachments-"));
16
+ const root = join(configDir(), 'message-attachments');
17
+ await mkdir(root, { recursive: true, mode: 0o700 });
18
+ const directory = await mkdtemp(join(root, "granttap-attachments-"));
19
+ let retained = false;
16
20
  try {
17
21
  const files = await writeAttachments(directory, attachments);
18
22
  const documents = files.filter((file) => !file.image);
19
23
  const documentNote = documents.length === 0 ? "" : `\n\nAttached files available locally:\n${documents.map(fileLine).join("\n")}`;
20
24
  const claudeNote = `\n\nAttached files available locally (inspect them as part of this request):\n${files.map(fileLine).join("\n")}`;
21
- return await run({
25
+ const result = await run({
22
26
  prompt: `${text || "Please inspect the attached content."}${documentNote}`,
23
27
  claudePrompt: `${text || "Please inspect the attached content."}${claudeNote}`,
24
28
  images: files.filter((file) => file.image).map((file) => file.path),
25
29
  });
30
+ retained = result.delivery === 'native' && documents.length > 0;
31
+ return result;
26
32
  } catch (error) {
27
33
  return { ok: false, error: `Could not prepare attachment: ${(error as Error).message}` };
28
34
  } finally {
29
- await rm(directory, { recursive: true, force: true });
35
+ if (!retained) await rm(directory, { recursive: true, force: true });
30
36
  }
31
37
  }
32
38
 
39
+ /** Native admission returns before the agent reads documents. Retain those for one day. */
40
+ export function prunePreparedAttachments(now = Date.now()): number {
41
+ const root = join(configDir(), 'message-attachments');
42
+ if (!existsSync(root)) return 0;
43
+ let count = 0;
44
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
45
+ if (!entry.isDirectory() || !/^granttap-attachments-[a-zA-Z0-9]+$/.test(entry.name)) continue;
46
+ try {
47
+ const path = join(root, entry.name);
48
+ const stat = lstatSync(path);
49
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || stat.mtimeMs > now - 86_400_000) continue;
50
+ rmSync(path, { recursive: true, force: true });
51
+ count++;
52
+ } catch { /* A concurrent delivery may already have removed its own stage. */ }
53
+ }
54
+ return count;
55
+ }
56
+
33
57
  type StagedFile = { path: string; image: boolean; name: string };
34
58
 
35
59
  async function writeAttachments(directory: string, attachments: UserAttachment[]): Promise<StagedFile[]> {
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { UserAttachment } from '../../../../../packages/protocol/schema';
3
+ import type { DeliveryOptions } from './types';
4
+
5
+ /** Restaging an attachment changes its local path, not the controller's intent. */
6
+ export function deliveryIntent(text: string, attachments: UserAttachment[], options: DeliveryOptions): string {
7
+ return createHash('sha256').update(JSON.stringify({ text,
8
+ attachments: attachments.map(file => ({ name: file.name, mimeType: file.mimeType,
9
+ digest: createHash('sha256').update(Buffer.from(file.data, 'base64')).digest('hex') })),
10
+ model: options.model, effort: options.effort, preferredMcp: options.preferredMcp,
11
+ skill: options.skill, permissionMode: options.permissionMode,
12
+ })).digest('hex');
13
+ }
@@ -1,8 +1,12 @@
1
1
  export type ReplyResult =
2
- | { ok: true; text: string; sessionId?: string }
3
- | { ok: false; error: string };
2
+ | { ok: true; text: string; sessionId?: string; delivery?: 'native' }
3
+ | { ok: false; error: string; delivery?: 'native' };
4
4
 
5
5
  export type DeliveryOptions = {
6
+ /** Stable controller delivery ID, also used by the provider's native admission. */
7
+ messageId?: string;
8
+ /** Computer-generated identity before temporary attachment paths are inserted. */
9
+ intentDigest?: string;
6
10
  preferredMcp?: string;
7
11
  skill?: string;
8
12
  model?: string;
@@ -4,6 +4,7 @@ import { resolveCodexBinary } from "../../providers/codex-bin";
4
4
  import { runProcess } from ".";
5
5
  import { providerEffortArgs } from "./provider-effort";
6
6
  import type { DeliveryOptions, ReplyResult } from "../payload/types";
7
+ import { deliverToCodexDesktop } from '../../codex-desktop';
7
8
 
8
9
  function parseCodexJsonl(stdout: string, fallbackSessionId?: string): ReplyResult {
9
10
  let lastMessage = "";
@@ -44,13 +45,15 @@ function parseCodexJsonl(stdout: string, fallbackSessionId?: string): ReplyResul
44
45
  : { ok: false, error: "Codex returned an empty response." };
45
46
  }
46
47
 
47
- export function runCodex(
48
+ export async function runCodex(
48
49
  session: SessionInfo,
49
50
  text: string,
50
51
  timeoutMs: number,
51
52
  images: string[] = [],
52
53
  options: DeliveryOptions = {},
53
54
  ): Promise<ReplyResult> {
55
+ const native = await deliverToCodexDesktop({ session, text, images, options, timeoutMs });
56
+ if (native) return native;
54
57
  const configured = loadRuntimeConfig().sessionAccess[session.sessionId];
55
58
  const disabledMcp = loadRuntimeConfig().sessionMcpDisabled[session.sessionId] ?? [];
56
59
  const sandbox = configured === "read-only"
@@ -71,7 +74,7 @@ export function runCodex(
71
74
  "exec", "resume", ...accessArgs, ...mcpArgs, ...modelArgs, ...providerEffortArgs("codex", options.effort), ...imageArgs,
72
75
  session.sessionId, "--json", "-",
73
76
  ];
74
- return runProcess(
77
+ const result = await runProcess(
75
78
  resolveCodexBinary(),
76
79
  args,
77
80
  session.cwd,
@@ -80,6 +83,9 @@ export function runCodex(
80
83
  text,
81
84
  session.sessionId,
82
85
  );
86
+ return !result.ok && /thread-store conflict|already has an active writer/i.test(result.error)
87
+ ? { ok: false, error: 'Codex already owns this chat. Keep its window open and retry the message in GrantTap.' }
88
+ : result;
83
89
  }
84
90
 
85
91
  function tomlKey(value: string): string {
@@ -3,6 +3,7 @@ import { setSessionPaused } from "../config";
3
3
  import { compactCodexSession } from "../providers/codex-control";
4
4
  import { deliverToSession, stopDeliveries } from "../reply";
5
5
  import { scanSessionHistory, scanSessions } from "../sessions";
6
+ import { interruptCodexDesktop } from '../codex-desktop';
6
7
 
7
8
  const DELIVERY_TIMEOUT_MS = 240_000;
8
9
 
@@ -31,6 +32,9 @@ export async function controlTaskSession(
31
32
  }
32
33
  if (message.action === "pause") {
33
34
  const stopped = stopDeliveries(sessionId);
35
+ const desktop = await interruptCodexDesktop(sessionId);
36
+ if (desktop?.ok) return reply(true,
37
+ 'Paused. Codex acknowledged the stop request. Every new tool call from this chat is refused until you resume.');
34
38
  return reply(true, stopped > 0
35
39
  ? `Paused. Stop requested for ${stopped} running turn${stopped === 1 ? "" : "s"}; process exit is not yet confirmed. Every new tool call from this chat is refused until you resume.`
36
40
  : "Paused: every tool call from this chat is refused until you resume.");
@@ -47,7 +47,7 @@ export function desktopTaskSend(input: unknown,
47
47
  if (prior) return prior;
48
48
  const attachments = readDesktopAttachments(query.attachments_json);
49
49
  if (!attachments || !text.trim() && attachments.length === 0) return Promise.resolve(undefined);
50
- const operation = deliver(session, text.trim(), 240_000, attachments, options).then((result) => ({
50
+ const operation = deliver(session, text.trim(), 240_000, attachments, { ...options, messageId: deliveryId }).then((result) => ({
51
51
  operation: "desktop.task_send", accepted: result.ok,
52
52
  error: result.ok ? null : result.error.slice(0, 500),
53
53
  }), () => ({ operation: "desktop.task_send", accepted: false,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.17",
4
+ "version": "0.1.18",
5
5
  "description": "GrantTap pairs this computer with iPhone. The connection card shows a one-time QR when Cursor supports interactive MCP Apps; an interactive terminal command is available if it does not. The website approves the coding app.",
6
6
  "author": {
7
7
  "name": "GrantTap",
@@ -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.77
13
+ npm install -g granttap-mcp@0.8.78
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.77\";\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 20+ 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.78\";\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 20+ 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.77";
10
+ const PACKAGE = "granttap-mcp@0.8.78";
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.77",
3
+ "version": "0.8.78",
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": {