granttap-mcp 0.8.78 → 0.8.80

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.
@@ -12,5 +12,11 @@ as a person's message. A real request next to that context is retained. Ordinary
12
12
  JSON requests remain visible. Native rollouts are read without modifying the
13
13
  provider's conversation files.
14
14
 
15
+ Forked child rollouts retain their first native identity. Copied parent metadata
16
+ and entries older than the child's creation are excluded from the child's
17
+ catalog, activity and usage; they must not replace the root Task or double count
18
+ its history. `tests/forked-agents.test.ts` covers cached rescans and all three
19
+ nested conversations through the public catalog and activity readers.
20
+
15
21
  Tests live in [`tests/`](tests/). The selected-Page regression covers the public
16
22
  catalog, cached rescans, live activity, and paginated transcript history.
@@ -227,7 +227,7 @@ export function codexActivity(session: SessionInfo): ActivityEntry[] {
227
227
  const out: ActivityEntry[] = [];
228
228
  const seen = new Set<string>();
229
229
  for (const source of sources) {
230
- const head = codexHeadRequest(source.path);
230
+ const head = source.child ? undefined : codexHeadRequest(source.path);
231
231
  if (head) {
232
232
  const createdAt = head.createdAt || session.startedAt;
233
233
  const childFields = source.child ? childEntryFields(source.child) : {};
@@ -0,0 +1,18 @@
1
+ import { safeParse, ts } from '../../../support/common';
2
+
3
+ /** Forked rollouts begin with their own metadata, then a copied parent log. */
4
+ export function ownCodexLines(lines: string[]): string[] {
5
+ const first = lines.find(line => safeParse(line)?.type === 'session_meta');
6
+ if (!first) return lines;
7
+ const native = safeParse(first);
8
+ const meta = native?.payload;
9
+ if (!meta?.source?.subagent?.thread_spawn?.parent_thread_id) return lines;
10
+ const startedAt = ts(native.timestamp ?? meta.timestamp);
11
+ return lines.filter(line => {
12
+ const row = safeParse(line);
13
+ if (!row) return false;
14
+ if (row.type === 'session_meta') return row.payload?.id === meta.id;
15
+ const at = ts(row.timestamp);
16
+ return !startedAt || !at || at >= startedAt;
17
+ });
18
+ }
@@ -166,7 +166,10 @@ export function parseCodexFile(file: string): CodexCandidate | undefined {
166
166
  const d = safeParse(line);
167
167
  if (d) applyCodexLine(d, state);
168
168
  }
169
- if (!state.title) adoptRequestTitle(state, codexHeadRequest(file, true)?.text);
169
+ if (!state.title && !state.child) adoptRequestTitle(state, codexHeadRequest(file, true)?.text);
170
+ if (!state.title && state.child?.agentPath) {
171
+ state.title = childTitle(state.child.agentPath.split('/').filter(Boolean).at(-1)?.replace(/_/g, ' '));
172
+ }
170
173
  state.title ??= state.provisionalTitle;
171
174
  state.lastActivityAt = Math.max(state.lastActivityAt, fileStat.mtimeMs);
172
175
  state.startedAt ||= fileStat.birthtimeMs || state.lastActivityAt;
@@ -2,6 +2,7 @@ import { toolDescription } from "../../support/activity-helpers";
2
2
  import { diffPreviewFromInput, sensitivePath, statsFromInput } from "../../support/edit-stats";
3
3
  import { redactSecrets } from "../../telemetry/command-preview";
4
4
  import { recordObservedWrite, writtenPaths } from "../../../mesh/observed/writes";
5
+ import { ownCodexLines } from './lineage';
5
6
  /**
6
7
  * Codex session logs:
7
8
  * ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
@@ -135,7 +136,7 @@ export function readCodexLogWindow(
135
136
  tailBytes = CODEX_SUMMARY_TAIL_BYTES,
136
137
  ): string[] {
137
138
  const size = statSync(path).size;
138
- if (size <= headBytes + tailBytes) return readFileSync(path, "utf8").split("\n");
139
+ if (size <= headBytes + tailBytes) return ownCodexLines(readFileSync(path, "utf8").split("\n"));
139
140
  const fd = openSync(path, "r");
140
141
  try {
141
142
  const head = Buffer.allocUnsafe(headBytes);
@@ -153,7 +154,7 @@ export function readCodexLogWindow(
153
154
  const tailBreak = tailText.indexOf("\n");
154
155
  tailText = tailBreak >= 0 ? tailText.slice(tailBreak + 1) : "";
155
156
  }
156
- return `${headText}${tailText}`.split("\n");
157
+ return ownCodexLines(`${headText}${tailText}`.split("\n"));
157
158
  } finally {
158
159
  closeSync(fd);
159
160
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.18",
4
+ "version": "0.1.19",
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.78
13
+ npm install -g granttap-mcp@0.8.80
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.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"
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.80\";\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.78";
10
+ const PACKAGE = "granttap-mcp@0.8.80";
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.78",
3
+ "version": "0.8.80",
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": {
@@ -1,17 +1,16 @@
1
1
  /** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
2
2
  import WebSocket from "ws";
3
- import { ManagedRelayLease, RelaySocketHealth, relayUpgradeHeaders } from "./socket-lifecycle";
3
+ import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, relayUpgradeHeaders } from "./socket-lifecycle";
4
4
  import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
5
5
  import { openRelayMessage } from "./relay-inbound";
6
6
  import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
7
7
  import { Payload, type Role } from "../protocol/schema";
8
- import { peerPublicKeys, sealWithTransferKey } from "./crypto";
8
+ import { peerPublicKeys } from "./crypto";
9
9
  import type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
10
10
  export type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
11
11
  import { RelayMessageAcceptance } from "./replay-store";
12
- import { sealEnvelope } from "./relay-envelope";
12
+ import { sealEnvelope, sealSessionPayload } from "./relay-envelope";
13
13
  export { sealEnvelope } from "./relay-envelope";
14
-
15
14
  /** Return true only after this consumer has durably accepted the payload. */
16
15
  type Listener = (p: Payload, peerPublicKey: string) => boolean | void | Promise<boolean | void>;
17
16
 
@@ -25,7 +24,7 @@ export class RelayClient {
25
24
  private intentionalClose = false;
26
25
  private connectPromise?: Promise<void>;
27
26
  private reconnectTimer?: NodeJS.Timeout;
28
- private reconnectDelay: number;
27
+ private readonly reconnect: RelayReconnectBackoff;
29
28
  private readonly socketHealth = new RelaySocketHealth();
30
29
  private readonly admissionLease = new ManagedRelayLease();
31
30
  private readonly sessionKeys = new Map<string, string>();
@@ -35,7 +34,7 @@ export class RelayClient {
35
34
  private readonly cfg: PeerConfig,
36
35
  private readonly opts: RelayClientOptions = {},
37
36
  ) {
38
- this.reconnectDelay = opts.minReconnectMs ?? 1_000;
37
+ this.reconnect = new RelayReconnectBackoff(opts.minReconnectMs, opts.maxReconnectMs);
39
38
  if (opts.onTaskDispatch) this.taskDispatchListeners.add(opts.onTaskDispatch);
40
39
  this.replay = new RelayMessageAcceptance(opts.replayPath);
41
40
  }
@@ -57,6 +56,7 @@ export class RelayClient {
57
56
  connect(timeoutMs = 10_000): Promise<void> {
58
57
  if (this.isConnected) return Promise.resolve();
59
58
  if (this.connectPromise) return this.connectPromise;
59
+ if (this.reconnectTimer) return Promise.reject(new Error('Relay reconnect is waiting for its retry window.'));
60
60
 
61
61
  this.intentionalClose = false;
62
62
  this.recipientRouting = false;
@@ -76,7 +76,7 @@ export class RelayClient {
76
76
  ws.send(frame, error => error ? reject(error) : resolve());
77
77
  }));
78
78
  this.frames = frames;
79
- let opened = false;
79
+ let opened = false, openedAt: number | undefined;
80
80
  const timer = setTimeout(() => {
81
81
  ws.terminate();
82
82
  reject(new Error(`relay connect timeout (${this.cfg.relayUrl})`));
@@ -85,7 +85,7 @@ export class RelayClient {
85
85
  ws.on("open", () => {
86
86
  opened = true;
87
87
  clearTimeout(timer);
88
- this.reconnectDelay = this.opts.minReconnectMs ?? 1_000;
88
+ openedAt = Date.now();
89
89
  this.socketHealth.start(ws, this.opts.pingIntervalMs ?? 20_000);
90
90
  this.admissionLease.start(ws, this.opts);
91
91
  // Announce ourselves. The envelope's cleartext (room, from) is what the
@@ -99,16 +99,21 @@ export class RelayClient {
99
99
  resolve();
100
100
  });
101
101
  ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
102
+ ws.on('unexpected-response', (_request, response) => {
103
+ this.reconnect.defer(response.headers['retry-after']);
104
+ response.resume(); ws.terminate();
105
+ clearTimeout(timer); reject(new Error('Relay upgrade was not accepted.'));
106
+ });
102
107
  ws.on("error", (err) => {
103
108
  clearTimeout(timer);
104
109
  if (!opened) reject(err);
105
110
  });
106
- ws.on("close", () => {
111
+ ws.on("close", (code, reason) => {
107
112
  frames.cancel();
108
113
  clearTimeout(timer);
109
114
  this.socketHealth.stop(ws);
110
115
  this.admissionLease.stop(ws);
111
- if (this.ws === ws) this.ws = undefined;
116
+ if (this.ws === ws) { this.reconnect.closed(openedAt, code, reason.toString()); this.ws = undefined; }
112
117
  if (!opened) reject(new Error(`relay connection closed (${this.cfg.relayUrl})`));
113
118
  this.scheduleReconnect();
114
119
  });
@@ -219,16 +224,7 @@ export class RelayClient {
219
224
  to: Role | "all" = this.otherRole(),
220
225
  options: SendOptions = {},
221
226
  ): Promise<void> {
222
- const key = this.sessionKeys.get(sessionId);
223
- if (!key) throw new Error(`no encryption key for task ${sessionId}`);
224
- const sealed = sealWithTransferKey(payload, key);
225
- await this.send({
226
- type: "session.sealed",
227
- sessionId,
228
- nonce: sealed.nonce,
229
- box: sealed.box,
230
- createdAt: Date.now(),
231
- }, to, options);
227
+ await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, options);
232
228
  }
233
229
 
234
230
  /** Register before connect so only durable receivers advertise task-job support. */
@@ -284,9 +280,7 @@ export class RelayClient {
284
280
 
285
281
  private scheduleReconnect(): void {
286
282
  if (!this.opts.autoReconnect || this.intentionalClose || this.reconnectTimer) return;
287
- const delay = this.reconnectDelay;
288
- const max = this.opts.maxReconnectMs ?? 15_000;
289
- this.reconnectDelay = Math.min(max, delay * 2);
283
+ const delay = this.reconnect.delay();
290
284
  this.reconnectTimer = setTimeout(() => {
291
285
  this.reconnectTimer = undefined;
292
286
  void this.connect().catch(() => {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Envelope, PROTOCOL_VERSION, type Payload, type Role } from "../protocol/schema";
3
3
  import { publicKeyIdentifier } from "./ciphertext-recipient";
4
- import { seal } from "./crypto";
4
+ import { seal, sealWithTransferKey } from "./crypto";
5
5
  import type { PeerConfig, SendOptions } from "./relay-client-types";
6
6
 
7
7
  /**
@@ -37,3 +37,10 @@ export function sealEnvelope(
37
37
  };
38
38
  return JSON.stringify(envelope);
39
39
  }
40
+
41
+ /** Apply the independent task encryption layer before transport encryption. */
42
+ export function sealSessionPayload(payload: Payload, sessionId: string, key: string | undefined): Payload {
43
+ if (!key) throw new Error(`no encryption key for task ${sessionId}`);
44
+ const { nonce, box } = sealWithTransferKey(payload, key);
45
+ return { type: 'session.sealed', sessionId, nonce, box, createdAt: Date.now() };
46
+ }
@@ -6,6 +6,15 @@ socket and does not rotate task keys or reload transcript state. A failed lookup
6
6
  cannot extend the server's signed expiry. Closing or replacing a socket cancels
7
7
  only its own timers.
8
8
 
9
+ Reconnect pacing resets only after a non-policy connection lived at least 30
10
+ seconds. Short handshakes keep exponential backoff and jitter. WebSocket
11
+ `retry-after` reasons and HTTP `Retry-After` are lower bounds even when they
12
+ exceed the ordinary retry cap. Foreground polling cannot bypass a scheduled
13
+ retry. None of these waits delay a submission already persisted on the relay.
14
+ Every endpoint advertises its public-key digest before the upgrade, including
15
+ transient machine hooks, so a server can refuse a cooled identity before it
16
+ receives any ciphertext. This digest grants no permissions or decryption rights.
17
+
9
18
  The real WebSocket integration is in `tests/core/managed-relay-renewal.test.ts`.
10
19
 
11
20
  Licensed under the GrantTap Commercial Source License in the repository root.
@@ -1,4 +1,5 @@
1
1
  export { ManagedRelayLease } from "./managed-relay-lease";
2
2
  export { RelaySocketHealth } from "./relay-socket-health";
3
+ export { RelayReconnectBackoff } from "./reconnect-backoff";
3
4
 
4
5
  export { relayUpgradeHeaders } from "./upgrade-headers";
@@ -0,0 +1,29 @@
1
+ /** Retry pacing survives refused handshakes and short-lived WebSockets. */
2
+ export class RelayReconnectBackoff {
3
+ private next: number;
4
+ private notBefore = 0;
5
+
6
+ constructor(private readonly minimum = 1_000, private readonly maximum = 15_000,
7
+ private readonly now = Date.now, private readonly random = Math.random) {
8
+ this.next = minimum;
9
+ }
10
+
11
+ closed(openedAt: number | undefined, code: number, reason: string): void {
12
+ const policy = code === 1008 || code === 1009 || (code >= 4400 && code <= 4499);
13
+ if (!policy && openedAt !== undefined && this.now() - openedAt >= 30_000) this.next = this.minimum;
14
+ this.defer(/(?:^|;\s*)retry-after=(\d{1,5})(?:;|$)/.exec(reason)?.[1]);
15
+ if (policy) this.notBefore = Math.max(this.notBefore, this.now() + Math.min(60_000, this.maximum));
16
+ }
17
+
18
+ /** Server Retry-After is a floor, independent of the ordinary reconnect cap. */
19
+ defer(value: string | string[] | undefined): void {
20
+ if (typeof value !== 'string' || !/^\d{1,5}$/.test(value)) return;
21
+ this.notBefore = Math.max(this.notBefore, this.now() + Number(value) * 1_000);
22
+ }
23
+
24
+ delay(): number {
25
+ const jittered = Math.round(Math.max(this.minimum, Math.min(this.maximum, this.next * (0.8 + this.random() * 0.4))));
26
+ this.next = Math.min(this.maximum, this.next * 2);
27
+ return Math.max(jittered, this.notBefore - this.now());
28
+ }
29
+ }
@@ -4,8 +4,7 @@ import { publicKeyIdentifier } from '../ciphertext-recipient';
4
4
  /** Bind optional mailbox routing to this endpoint's real encryption identity. */
5
5
  export function relayUpgradeHeaders(cfg: PeerConfig, admission: Record<string, string>, durableReceiver: boolean) {
6
6
  return { ...admission, 'X-GrantTap-Recipient-Routing': '1',
7
- ...((cfg.role === 'phone' || durableReceiver)
8
- ? { 'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey) } : {}),
7
+ 'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey),
9
8
  ...(durableReceiver ? { 'X-GrantTap-Task-Dispatch': '1' } : {}),
10
9
  ...(cfg.pushAuth ? { Authorization: `Bearer ${cfg.pushAuth}` } : {}) };
11
10
  }