granttap-mcp 0.8.74 → 0.8.75

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.
@@ -67,7 +67,7 @@ export async function handleMonitorMessage(
67
67
  return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
68
68
  }
69
69
  if (payload.type === "user.message") {
70
- return handleInboundUserMessage(client, payload, publish);
70
+ return handleInboundUserMessage(client, payload, publish, input.peerPublicKey);
71
71
  } else if (payload.type === "user.attachment") {
72
72
  // Ahead of its message, so the message itself travels light.
73
73
  return storeAttachment(payload, client.room);
@@ -17,6 +17,7 @@ import { evaluateCreateTask } from "../../mesh/tasks/create-task";
17
17
  import { sendSessionPayload } from "../../host/session-keys";
18
18
  import { scanSessions } from "../../sessions";
19
19
  import { publishedSession } from "../../session-catalog";
20
+ import { localTaskOrigins, type TaskOriginRegistry, type TaskOriginScope } from '../../task-origins';
20
21
  import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
21
22
  import { resolveMessageAttachments } from "./receipts";
22
23
  import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
@@ -26,6 +27,7 @@ async function startNewAgentTask(input: {
26
27
  message: UserMessage;
27
28
  say: (text: string, sessionId?: string, wake?: boolean, kind?: "status" | "response") => Promise<unknown>;
28
29
  onProcessing?: () => Promise<void>;
30
+ origin?: { registry: TaskOriginRegistry; scope: TaskOriginScope };
29
31
  }): Promise<"accepted" | "rejected"> {
30
32
  const { client, message, say } = input;
31
33
  const agent = message.agent ?? "codex";
@@ -60,7 +62,19 @@ async function startNewAgentTask(input: {
60
62
  }
61
63
  return "rejected";
62
64
  }
63
- await input.onProcessing?.();
65
+ const admission = input.origin?.registry.begin(input.origin.scope,
66
+ { ...message, attachments: resolved.attachments, attachmentRefs: undefined });
67
+ if (admission && admission.state !== 'started') {
68
+ await say(admission.state === 'ready' ? 'This task has already been created.'
69
+ : 'The original task is being created or needs confirmation before it can be retried.',
70
+ admission.sessionId, false, 'status');
71
+ return ['ready', 'waiting'].includes(admission.state) ? 'accepted' : 'rejected';
72
+ }
73
+ try { await input.onProcessing?.(); }
74
+ catch (error) {
75
+ if (input.origin) input.origin.registry.abandonBeforeStart(input.origin.scope);
76
+ throw error;
77
+ }
64
78
  await say(`Creating a new ${displayName} task…`, undefined, false, "status");
65
79
  const create = {
66
80
  claude: createClaudeSession,
@@ -68,13 +82,25 @@ async function startNewAgentTask(input: {
68
82
  cursor: createCursorSession,
69
83
  grok: createGrokSession,
70
84
  }[agent];
71
- const run = create(
72
- message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model, undefined, message.effort,
73
- );
74
- const settle = (result: ReplyResult, late: boolean) => result.ok
75
- ? say(result.text, result.sessionId, true)
76
- : say(late ? `The ${displayName} task stopped: ${result.error}`
77
- : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
85
+ let run: Promise<ReplyResult>;
86
+ try {
87
+ run = create(message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments,
88
+ message.model, undefined, message.effort).catch(() => {
89
+ input.origin?.registry.complete(input.origin.scope, { ok: true });
90
+ return { ok: false, error: 'The provider task stopped unexpectedly.' } as ReplyResult;
91
+ });
92
+ } catch {
93
+ input.origin?.registry.complete(input.origin.scope, { ok: false });
94
+ await say(`Could not create a ${displayName} task on this computer.`, undefined, true);
95
+ return 'rejected';
96
+ }
97
+ const settle = (result: ReplyResult, late: boolean) => {
98
+ input.origin?.registry.complete(input.origin.scope,
99
+ { ok: result.ok, sessionId: result.ok ? result.sessionId : undefined });
100
+ return result.ok ? say(result.text, result.sessionId, true)
101
+ : say(late ? `The ${displayName} task stopped: ${result.error}`
102
+ : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
103
+ };
78
104
  const early = await acceptWhenStarted(run, (result) => settle(result, true));
79
105
  if (early === "started") return "accepted";
80
106
  await settle(early, false);
@@ -85,6 +111,7 @@ export async function handleUserMessage(
85
111
  client: RelayClient,
86
112
  message: UserMessage,
87
113
  onProcessing?: () => Promise<void>,
114
+ peerPublicKey?: string,
88
115
  ): Promise<"accepted" | "rejected"> {
89
116
  const say = (text: string, sessionId?: string, wake = false, kind: "status" | "response" = "response") => {
90
117
  const payload = agentEventForUserMessage(message, text, sessionId, kind);
@@ -94,7 +121,11 @@ export async function handleUserMessage(
94
121
  : client.send(payload, "phone", options)).catch(() => {});
95
122
  };
96
123
  if (!message.sessionId) {
97
- return startNewAgentTask({ client, message, say, onProcessing });
124
+ const origin = message.taskOriginId && peerPublicKey ? {
125
+ registry: localTaskOrigins(),
126
+ scope: { room: client.room, peerPublicKey, originMessageId: message.taskOriginId },
127
+ } : undefined;
128
+ return startNewAgentTask({ client, message, say, onProcessing, origin });
98
129
  }
99
130
  const resolved = resolveMessageAttachments(message, (attachmentId) => takeAttachment(attachmentId, client.room));
100
131
  if (!resolved.ok) {
@@ -9,6 +9,7 @@ import { handleUserMessage } from "../handlers/user-message";
9
9
  export async function handleInboundUserMessage(
10
10
  client: RelayClient, payload: Extract<Payload, { type: "user.message" }>,
11
11
  publish: (forceHistory?: boolean) => Promise<void>,
12
+ peerPublicKey?: string,
12
13
  ): Promise<boolean> {
13
14
  if (payload.requestId) return false;
14
15
  let deliveryStarted = false;
@@ -27,7 +28,7 @@ export async function handleInboundUserMessage(
27
28
  if (!payload.messageId) return;
28
29
  acknowledgeProcessingDelivery(payload.messageId);
29
30
  await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
30
- });
31
+ }, peerPublicKey);
31
32
  if (payload.messageId && deliveryStarted) {
32
33
  if (outcome === "rejected") abandonDelivery(payload.messageId);
33
34
  else {
@@ -27,6 +27,7 @@ import { scanCapabilityUsage, scanSessionHistory, scanSessions, scopeCapabilityU
27
27
  import { boundedCatalogHistory, INTERVAL_MS, publishSessionEvents } from "../handlers/catalog";
28
28
  import { sweepAttachments } from "../handlers/receipts";
29
29
  import { handleMonitorMessage } from "../handlers/inbound";
30
+ import { decorateTaskOriginCatalog } from '../../task-origins';
30
31
 
31
32
  const HISTORY_INTERVAL_MS = 60_000;
32
33
  const resumedHeads = new WeakSet<RelayClient>();
@@ -124,7 +125,8 @@ async function publishMonitorTick(input: {
124
125
  if (!leadership.acquire()) return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
125
126
  const initial = lastHistoryPublishedAt === 0;
126
127
  const includeHistory = forceHistory || (!initial && Date.now() - lastHistoryPublishedAt >= HISTORY_INTERVAL_MS);
127
- const status = monitorSnapshot({ includeHistory, forceHistory, subscriptions, history });
128
+ const status = decorateTaskOriginCatalog(
129
+ monitorSnapshot({ includeHistory, forceHistory, subscriptions, history }), client.room);
128
130
  rememberPublishedCatalog(client, status);
129
131
  await client.send(status, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false });
130
132
  if (initial && !resumedHeads.has(client)) {
@@ -12,8 +12,10 @@ export type WorkerOptions = {
12
12
  onTerminal?: (job: TaskDispatch) => void;
13
13
  onError?: () => void;
14
14
  connected: () => boolean; authorized: (peer: string) => boolean;
15
- ready: (job: TaskDispatch) => boolean;
16
- deliver: (job: TaskDispatch, beforeStart: () => Promise<void>) => Promise<'accepted' | 'rejected'>;
15
+ conversationKey?: (job: TaskDispatch, peer: string) => string;
16
+ validationCode?: (job: TaskDispatch, peer: string) => string | undefined;
17
+ ready: (job: TaskDispatch, peer: string) => boolean;
18
+ deliver: (job: TaskDispatch, beforeStart: () => Promise<void>, peer: string) => Promise<'accepted' | 'rejected'>;
17
19
  };
18
20
  class DeferredLaunch extends Error {}
19
21
 
@@ -31,7 +33,8 @@ export class TaskDispatchWorker {
31
33
  || a.job.createdAt - b.job.createdAt);
32
34
  for (const record of records) {
33
35
  if (['completed', 'cancelled'].includes(record.phase) || this.active.has(record.job.jobId)) continue;
34
- const key = record.job.sessionId ?? `new:${record.job.operation.agent ?? 'codex'}`;
36
+ const key = this.options.conversationKey?.(record.job, record.peerPublicKey)
37
+ ?? record.job.sessionId ?? `new:${record.job.operation.agent ?? 'codex'}`;
35
38
  if (this.conversations.has(key)) continue;
36
39
  this.conversations.add(key);
37
40
  const work = this.process(record).catch(() => { this.options.onError?.(); }).finally(() => {
@@ -71,8 +74,11 @@ export class TaskDispatchWorker {
71
74
  inbox.transition(job.jobId, 'failed', { code: 'already_finished' });
72
75
  inbox.transition(job.jobId, 'completed'); inbox.retire(job.jobId); return;
73
76
  }
74
- if (job.notBefore > Date.now() || !this.options.ready(job)) return;
77
+ if (job.notBefore > Date.now()) return;
75
78
  if (!this.options.authorized(record.peerPublicKey)) return this.reject(record, 'policy_denied');
79
+ const validation = this.options.validationCode?.(job, record.peerPublicKey);
80
+ if (validation) return this.reject(record, validation);
81
+ if (!this.options.ready(job, record.peerPublicKey)) return;
76
82
  let started = false;
77
83
  try {
78
84
  const outcome = await this.options.deliver(job, async () => {
@@ -85,7 +91,7 @@ export class TaskDispatchWorker {
85
91
  throw new DeferredLaunch();
86
92
  }
87
93
  started = true;
88
- });
94
+ }, record.peerPublicKey);
89
95
  if (!started) { if (outcome === 'rejected') await this.reject(record, 'policy_denied'); return; }
90
96
  inbox.transition(job.jobId, outcome === 'accepted' ? 'delivered' : 'failed',
91
97
  outcome === 'rejected' ? { code: 'delivery_failed' } : {});
@@ -10,6 +10,7 @@ import { TaskDispatchWorker } from './index';
10
10
  import { relayTaskJobRemote } from './remote';
11
11
  import { NativeTaskQueueSender } from '../sender';
12
12
  import { queuedCatalogRefreshDue, taskDispatchReady } from './readiness';
13
+ import { localTaskOrigins, resolveTaskOriginJob } from '../../task-origins';
13
14
 
14
15
  /** Only the persistent monitor advertises durable submission capability. */
15
16
  export function startTaskDispatchMonitor(input: {
@@ -17,6 +18,9 @@ export function startTaskDispatchMonitor(input: {
17
18
  peerAllowed: (peer: string) => boolean; publish: () => Promise<void>;
18
19
  }): () => void {
19
20
  const inbox = new TaskDispatchInbox(join(configDir(), 'task-dispatch'), input.config.room);
21
+ const origins = localTaskOrigins();
22
+ const resolve = (job: Parameters<typeof resolveTaskOriginJob>[1], peer: string) =>
23
+ resolveTaskOriginJob(origins, job, input.config.room, peer);
20
24
  let stopped = false, reportedAt = 0, maintainedAt = 0, refreshedAt = 0;
21
25
  const sender = new NativeTaskQueueSender({ root: join(configDir(), 'native-task-queue'), config: input.config });
22
26
  const peerAllowed = (peer: string) => peer === input.config.myPublicKey || input.peerAllowed(peer);
@@ -25,7 +29,17 @@ export function startTaskDispatchMonitor(input: {
25
29
  authorized: peerAllowed,
26
30
  onTerminal: job => sender.confirmReceiverOutcome(job),
27
31
  onError: () => reportFailure(),
28
- ready: job => {
32
+ conversationKey: (job, peer) => resolve(job, peer).job?.sessionId
33
+ ?? (job.operation.type === 'user.message.continue'
34
+ ? JSON.stringify([peer, job.operation.originMessageId]) : `new:${job.operation.agent ?? 'codex'}`),
35
+ validationCode: (job, peer) => {
36
+ const state = resolve(job, peer).state;
37
+ return state === 'uncertain' ? 'confirmation_required'
38
+ : state === 'failed' ? 'delivery_failed' : state === 'conflict' ? 'policy_denied' : undefined;
39
+ },
40
+ ready: (original, peer) => {
41
+ const job = resolve(original, peer).job;
42
+ if (!job) return false;
29
43
  const catalog = publishedCatalog(input.client);
30
44
  const session = [...(catalog?.sessions ?? []), ...(catalog?.history ?? [])]
31
45
  .find(item => item.sessionId === job.sessionId);
@@ -39,9 +53,11 @@ export function startTaskDispatchMonitor(input: {
39
53
  }
40
54
  return ready;
41
55
  },
42
- deliver: async (job, beforeStart) => {
56
+ deliver: async (original, beforeStart, peer) => {
57
+ const job = resolve(original, peer).job;
58
+ if (!job || job.operation.type === 'user.message.continue') return 'rejected';
43
59
  const outcome = job.operation.type === 'user.message'
44
- ? await handleUserMessage(input.client, job.operation, beforeStart)
60
+ ? await handleUserMessage(input.client, job.operation, beforeStart, peer)
45
61
  : (await handleTaskCreate(input.client, job.operation, beforeStart)).status;
46
62
  void input.publish().catch(() => {});
47
63
  return outcome;
@@ -0,0 +1,22 @@
1
+ # Native task origins
2
+
3
+ Public entry: `index.ts`. The receiver records the authenticated controller,
4
+ room and original message before starting a native task. A completed native
5
+ creation supplies the session ID. A continuation never supplies or invents it.
6
+
7
+ Private records contain a request digest and routing metadata, never prompt
8
+ text or attachments. Atomic, flushed writes and a process lock preserve the
9
+ mapping across restarts. An interrupted owner, corrupt record, changed request
10
+ or missing native ID cannot authorize another new task.
11
+
12
+ Tests live in `tests/`. Origin lookup does not bypass normal provider, pause,
13
+ Project or controller authorization.
14
+
15
+ Only clients that opt into origin registration change creation behavior.
16
+ Transport timestamps and retries keep one origin. A definite failure can be
17
+ retried with a new explicit controller retry generation; an interrupted or
18
+ unknown native attempt requires confirmation instead of automatic replay.
19
+
20
+ ## License
21
+
22
+ Licensed under the Apache License, Version 2.0 (Apache-2.0). See LICENSE and NOTICE.
@@ -0,0 +1,14 @@
1
+ import type { TaskDispatch } from '../../../../packages/protocol/schema';
2
+ import type { TaskOriginRegistry } from './index';
3
+
4
+ export function resolveTaskOriginJob(registry: TaskOriginRegistry, job: TaskDispatch,
5
+ room: string, peerPublicKey: string): { state: string; job?: TaskDispatch } {
6
+ if (job.operation.type !== 'user.message.continue') return { state: 'ready', job };
7
+ const origin = registry.lookup({ room, peerPublicKey, originMessageId: job.operation.originMessageId },
8
+ job.operation.agent, job.operation.projectId);
9
+ if (origin.state !== 'ready') return { state: origin.state };
10
+ const { originMessageId: _origin, type: _type, ...message } = job.operation;
11
+ return { state: 'ready', job: { ...job, sessionId: origin.sessionId,
12
+ operation: { ...message, type: 'user.message', sessionId: origin.sessionId,
13
+ projectId: origin.projectId } } };
14
+ }
@@ -0,0 +1,139 @@
1
+ import { mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import type { UserMessage } from '../../../../packages/protocol/schema';
5
+ import { withStoreLock } from '../../../../packages/core/store-file-lock';
6
+ import { fingerprint, readOrigin, syncDirectory, writeOrigin } from './storage';
7
+ import { configDir } from '../config';
8
+ export { resolveTaskOriginJob } from './continuation';
9
+ export { decorateTaskOriginCatalog } from './projection';
10
+
11
+ const Scope = z.object({ room: z.string().min(1).max(180), peerPublicKey: z.string().min(1).max(180),
12
+ originMessageId: z.string().min(1).max(180) }).strict();
13
+ export type TaskOriginScope = z.infer<typeof Scope>;
14
+ const Record = z.object({ scope: Scope, digest: z.string().length(64),
15
+ agent: z.enum(['codex', 'claude', 'cursor', 'grok']), projectId: z.string().optional(),
16
+ phase: z.enum(['creating', 'ready', 'failed', 'uncertain']),
17
+ owner: z.number().int().positive(), createdAt: z.number().finite(), expiresAt: z.number().finite(),
18
+ lastRetryId: z.string().optional(),
19
+ sessionId: z.string().min(1).max(180).optional() }).strict();
20
+ type OriginRecord = z.infer<typeof Record>;
21
+ export type OriginResolution = { state: 'started' | 'waiting' | 'ready' | 'failed' | 'uncertain' | 'conflict';
22
+ sessionId?: string; projectId?: string };
23
+
24
+ /** Only a native creation result can resolve this controller's original message to a chat. */
25
+ export class TaskOriginRegistry {
26
+ private readonly catalogCache = new Map<string, { version: string; record: OriginRecord }>();
27
+ private readonly now: () => number;
28
+ private readonly ownerAlive: (pid: number) => boolean;
29
+ constructor(private readonly root: string, options: {
30
+ now?: () => number; ownerAlive?: (pid: number) => boolean;
31
+ } = {}) {
32
+ this.now = options.now ?? Date.now;
33
+ this.ownerAlive = options.ownerAlive ?? (pid => {
34
+ try { process.kill(pid, 0); return true; }
35
+ catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; }
36
+ });
37
+ }
38
+
39
+ begin(scope: TaskOriginScope, message: UserMessage): OriginResolution {
40
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
41
+ syncDirectory(dirname(this.root));
42
+ const { createdAt: _time, retryId: _retry, ...intent } = message;
43
+ const path = this.path(scope), digest = fingerprint(intent);
44
+ return withStoreLock(path, () => {
45
+ const existing = this.read(scope);
46
+ if (existing) {
47
+ if (existing.digest !== digest) return { state: 'conflict' };
48
+ if (existing.phase !== 'failed' || !message.retryId || message.retryId === existing.lastRetryId) {
49
+ return this.resolve(existing, message.agent ?? 'codex', message.projectId);
50
+ }
51
+ }
52
+ const record = Record.parse({ scope, digest, agent: message.agent ?? 'codex',
53
+ projectId: message.projectId, phase: 'creating', owner: process.pid,
54
+ lastRetryId: message.retryId,
55
+ createdAt: this.now(),
56
+ expiresAt: this.now() + 37 * 86_400_000 });
57
+ writeOrigin(path, record);
58
+ return { state: 'started' };
59
+ });
60
+ }
61
+
62
+ complete(scope: TaskOriginScope, result: { ok: boolean; sessionId?: string }): void {
63
+ const path = this.path(scope);
64
+ withStoreLock(path, () => {
65
+ const record = this.read(scope);
66
+ if (!record || record.owner !== process.pid) throw new Error('Task origin owner mismatch');
67
+ if (record.phase !== 'creating') return;
68
+ const candidate = result.sessionId?.trim();
69
+ const sessionId = candidate && candidate.length <= 180 ? candidate : undefined;
70
+ const phase = !result.ok ? 'failed' : sessionId ? 'ready' : 'uncertain';
71
+ writeOrigin(path, Record.parse({ ...record, phase, sessionId }));
72
+ });
73
+ }
74
+
75
+ /** Only a creation that never reached its native invocation may release its reservation. */
76
+ abandonBeforeStart(scope: TaskOriginScope): void {
77
+ const path = this.path(scope);
78
+ withStoreLock(path, () => {
79
+ const record = this.read(scope);
80
+ if (record?.owner !== process.pid || record.phase !== 'creating') return;
81
+ unlinkSync(path);
82
+ syncDirectory(this.root);
83
+ });
84
+ }
85
+
86
+ lookup(scope: TaskOriginScope, agent: string, projectId?: string): OriginResolution {
87
+ const record = this.read(scope);
88
+ return record ? this.resolve(record, agent, projectId) : { state: 'waiting' };
89
+ }
90
+
91
+ readyInRoom(room: string) {
92
+ return readdirSync(this.root).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort().flatMap(name => {
93
+ try {
94
+ const path = join(this.root, name), stat = statSync(path);
95
+ const version = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
96
+ const cached = this.catalogCache.get(path);
97
+ const record = cached?.version === version ? cached.record : Record.parse(readOrigin(path));
98
+ if (this.path(record.scope) !== path) return [];
99
+ this.catalogCache.set(path, { version, record });
100
+ if (record.scope.room !== room || record.phase !== 'ready' || !record.sessionId
101
+ || record.expiresAt <= this.now()) return [];
102
+ return [{ sessionId: record.sessionId, agent: record.agent, projectId: record.projectId,
103
+ originId: record.scope.originMessageId, controllerPublicKey: record.scope.peerPublicKey }];
104
+ } catch { return []; }
105
+ });
106
+ }
107
+
108
+ private resolve(record: OriginRecord, agent: string, projectId?: string): OriginResolution {
109
+ if (record.agent !== agent || (projectId !== undefined && projectId !== record.projectId)) {
110
+ return { state: 'conflict' };
111
+ }
112
+ if (record.expiresAt <= this.now()) return { state: 'uncertain' };
113
+ if (record.phase === 'creating') {
114
+ const withinRunLimit = this.now() - record.createdAt <= 6 * 3_600_000 + 60_000;
115
+ return { state: withinRunLimit && this.ownerAlive(record.owner) ? 'waiting' : 'uncertain' };
116
+ }
117
+ if (record.phase !== 'ready' || !record.sessionId) return { state: record.phase === 'failed' ? 'failed' : 'uncertain' };
118
+ return { state: 'ready', sessionId: record.sessionId, projectId: record.projectId };
119
+ }
120
+
121
+ private read(scope: TaskOriginScope): OriginRecord | undefined {
122
+ const value = readOrigin(this.path(scope));
123
+ if (value === undefined) return undefined;
124
+ const record = Record.parse(value);
125
+ if (fingerprint(record.scope) !== fingerprint(scope)) throw new Error('Task origin scope mismatch');
126
+ return record;
127
+ }
128
+
129
+ private path(scope: TaskOriginScope): string {
130
+ return join(this.root, fingerprint(Scope.parse(scope)) + '.json');
131
+ }
132
+ }
133
+
134
+ let cachedLocal: { root: string; registry: TaskOriginRegistry } | undefined;
135
+ export function localTaskOrigins(): TaskOriginRegistry {
136
+ const root = join(configDir(), 'task-origins');
137
+ if (cachedLocal?.root !== root) cachedLocal = { root, registry: new TaskOriginRegistry(root) };
138
+ return cachedLocal.registry;
139
+ }
@@ -0,0 +1,20 @@
1
+ import type { SessionInfo, SessionsStatus } from '../../../../packages/protocol/schema';
2
+ import { localTaskOrigins, type TaskOriginRegistry } from './index';
3
+
4
+ /** A fresh catalog heals a controller that was offline when the creation response was sent. */
5
+ export function decorateTaskOriginCatalog(status: SessionsStatus, room: string,
6
+ registry?: TaskOriginRegistry): SessionsStatus {
7
+ let origins: ReturnType<TaskOriginRegistry['readyInRoom']>;
8
+ try { origins = (registry ?? localTaskOrigins()).readyInRoom(room); }
9
+ catch { return status; }
10
+ const decorate = (session: SessionInfo): SessionInfo => {
11
+ const matching = origins.filter(origin => origin.sessionId === session.sessionId && origin.agent === session.agent
12
+ && (!origin.projectId || !session.projectId || origin.projectId === session.projectId));
13
+ if (!matching.length && !session.creationOrigins) return session;
14
+ const { creationOrigins: _previous, ...native } = session;
15
+ return matching.length ? { ...native, creationOrigins: matching.slice(0, 32).map(origin => ({
16
+ originId: origin.originId, controllerPublicKey: origin.controllerPublicKey,
17
+ })) } : native;
18
+ };
19
+ return { ...status, sessions: status.sessions.map(decorate), history: status.history?.map(decorate) };
20
+ }
@@ -0,0 +1,42 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ export function fingerprint(value: unknown): string {
6
+ return createHash('sha256').update(JSON.stringify(value, (_key, item) => {
7
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
8
+ return Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]]));
9
+ })).digest('hex');
10
+ }
11
+
12
+ export function readOrigin(path: string): unknown {
13
+ try {
14
+ const record = JSON.parse(readFileSync(path, 'utf8'));
15
+ if (record.digest !== fingerprint(record.value)) throw new Error('Unreadable task origin');
16
+ return record.value;
17
+ } catch (error) {
18
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
19
+ throw error;
20
+ }
21
+ }
22
+
23
+ export function writeOrigin(path: string, value: unknown): void {
24
+ const temporary = `${path}.${randomUUID()}.tmp`;
25
+ const fd = openSync(temporary, 'wx', 0o600);
26
+ try {
27
+ try { writeFileSync(fd, JSON.stringify({ value, digest: fingerprint(value) })); fsyncSync(fd); }
28
+ finally { closeSync(fd); }
29
+ renameSync(temporary, path);
30
+ syncDirectory(dirname(path));
31
+ } finally {
32
+ try { unlinkSync(temporary); } catch { /* Rename normally consumed it. */ }
33
+ }
34
+ }
35
+
36
+ export function syncDirectory(path: string): void {
37
+ let fd: number | undefined;
38
+ try { fd = openSync(path, 'r'); fsyncSync(fd); }
39
+ catch (error) {
40
+ if (!['EINVAL', 'ENOTSUP', 'EPERM', 'EISDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
41
+ } finally { if (fd !== undefined) closeSync(fd); }
42
+ }
@@ -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.74
13
+ npm install -g granttap-mcp@0.8.75
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.74\";\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.75\";\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.74";
10
+ const PACKAGE = "granttap-mcp@0.8.75";
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.74",
3
+ "version": "0.8.75",
4
4
  "description": "Apache-2.0 computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,6 +41,10 @@ export const ATTACHMENT_MISSING_ERROR = "attachment-missing";
41
41
  export const UserMessage = z.object({
42
42
  type: z.literal("user.message"),
43
43
  messageId: z.string().min(1).max(180).optional(),
44
+ /** Opt-in origin registration for follow-ups sent before a native chat ID exists. */
45
+ taskOriginId: z.string().min(1).max(180).optional(),
46
+ /** Changes only after an explicit controller Retry, never a transport retry. */
47
+ retryId: z.string().min(1).max(180).optional(),
44
48
  text: z.string(),
45
49
  agent: CodingAgent.optional(),
46
50
  cwd: z.string().max(4_096).optional(),
@@ -65,6 +65,8 @@ export const SessionSealed = z.object({
65
65
  export type SessionSealed = z.infer<typeof SessionSealed>;
66
66
 
67
67
  export const SessionInfo = z.object({
68
+ creationOrigins: z.array(z.object({ originId: z.string().min(1).max(180),
69
+ controllerPublicKey: z.string().min(1).max(180) }).strict()).max(32).optional(),
68
70
  transfer: TaskTransferNotice.optional(),
69
71
  sessionId: z.string(),
70
72
  agent: AgentId,
@@ -15,3 +15,10 @@ Tests live in `tests/wire.test.ts`.
15
15
  ## License
16
16
 
17
17
  Licensed under the Apache License, Version 2.0 (Apache-2.0). See LICENSE and NOTICE.
18
+
19
+ ## Pending native chats
20
+
21
+ `user.message.continue` names an original creation ID and has no native session
22
+ ID or workspace override. MCP 0.8.75 resolves it using the authenticated sender
23
+ and room before normal provider and Project validation. Older receivers reject
24
+ the unknown operation; it can never be interpreted as a new task.
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ import { UserMessage } from '../messages/interaction';
3
+ import { CodingAgent } from '../messages/primitives';
4
+
5
+ /** A separate operation fails closed on receivers that do not support origin routing. */
6
+ export const TaskContinuation = UserMessage.omit({ type: true, sessionId: true, requestId: true, cwd: true,
7
+ taskOriginId: true, retryId: true })
8
+ .extend({ type: z.literal('user.message.continue'), agent: CodingAgent,
9
+ messageId: z.string().min(1).max(180), originMessageId: z.string().min(1).max(180) }).strict();
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { TaskCreate, UserMessage } from '../messages/interaction';
3
+ import { TaskContinuation } from './continuation';
3
4
 
4
5
  /** Signed scheduling and acceptance proof live inside authenticated ciphertext. */
5
6
  export const TaskDispatch = z.object({
@@ -12,7 +13,7 @@ export const TaskDispatch = z.object({
12
13
  notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
13
14
  expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
14
15
  createdAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
15
- operation: z.union([UserMessage, TaskCreate]),
16
+ operation: z.union([UserMessage, TaskCreate, TaskContinuation]),
16
17
  }).strict().superRefine((job, context) => {
17
18
  const reject = (message: string, path: string[]) => context.addIssue({
18
19
  code: z.ZodIssueCode.custom, message, path,
@@ -31,6 +32,10 @@ export const TaskDispatch = z.object({
31
32
  } else if (job.sessionId !== undefined) {
32
33
  reject('New tasks cannot borrow an existing conversation encryption scope', ['sessionId']);
33
34
  }
35
+ if (job.operation.type === 'user.message.continue'
36
+ && job.operation.originMessageId === job.operation.messageId) {
37
+ reject('A continuation cannot depend on itself', ['operation', 'originMessageId']);
38
+ }
34
39
  if (job.operation.attachmentRefs?.length) {
35
40
  reject('Durable tasks must carry their attachments inline', ['operation', 'attachmentRefs']);
36
41
  }