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 +6 -0
- package/apps/bridge/src/codex-desktop/README.md +31 -0
- package/apps/bridge/src/codex-desktop/connection.ts +104 -0
- package/apps/bridge/src/codex-desktop/controls.ts +21 -0
- package/apps/bridge/src/codex-desktop/index.ts +78 -0
- package/apps/bridge/src/codex-desktop/receipts.ts +55 -0
- package/apps/bridge/src/codex-desktop/socket-path.ts +25 -0
- package/apps/bridge/src/monitor/handlers/receipts.ts +2 -1
- package/apps/bridge/src/monitor/handlers/user-message.ts +2 -0
- package/apps/bridge/src/reply/README.md +6 -0
- package/apps/bridge/src/reply/index.ts +3 -0
- package/apps/bridge/src/reply/payload/attachments.ts +29 -5
- package/apps/bridge/src/reply/payload/identity.ts +13 -0
- package/apps/bridge/src/reply/payload/types.ts +6 -2
- package/apps/bridge/src/reply/process/codex-headless.ts +8 -2
- package/apps/bridge/src/task-control/index.ts +4 -0
- package/apps/mcp/src/desktop/delivery/task-send.ts +1 -1
- package/cursor-plugin/.cursor-plugin/plugin.json +1 -1
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
package/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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
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.78
|
|
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.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.
|
|
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;
|