granttap-mcp 0.8.23 → 0.8.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,11 @@ hosted service is governed by its own terms.
43
43
  GrantTap reports the depth each provider actually exposes. Visibility does not
44
44
  imply deterministic remote blocking or full mobile continuation.
45
45
 
46
+ Session catalogs report `lastMessageAt` only when a provider transcript has a
47
+ timestamped user or assistant message. `lastActivityAt` remains the separate
48
+ runtime activity timestamp and can include metadata or file updates. Clients
49
+ label that fallback as activity rather than presenting it as a message time.
50
+
46
51
  ## Project Mesh and task handoff
47
52
 
48
53
  Project Mesh adds stable Project and Task identity above provider-native
@@ -137,6 +137,8 @@ function addComposerSession(context: ScanContext, composer: ComposerRow): void {
137
137
  sessionId: composer.id, files, cwd: composer.cwd, composers: context.composers, descendants,
138
138
  });
139
139
  const childLast = child.children.reduce((latest, item) => Math.max(latest, item.lastActivityAt), 0);
140
+ const lastMessageAt = Math.max(root.lastMessageAt ?? 0,
141
+ ...child.summaries.map((item) => item.lastMessageAt ?? 0));
140
142
  const lastActivityAt = Math.max(
141
143
  composerActivityAt(composer), root.lastActivityAt, childLast,
142
144
  );
@@ -153,6 +155,7 @@ function addComposerSession(context: ScanContext, composer: ComposerRow): void {
153
155
  root.startedAt || lastActivityAt || context.now,
154
156
  ),
155
157
  lastActivityAt: lastActivityAt || composer.createdAt,
158
+ ...(lastMessageAt ? { lastMessageAt } : {}),
156
159
  tokensSession,
157
160
  tokensLastTurn: root.files.length ? root.tokensLastTurn : 0,
158
161
  contextTokensUsed: composer.contextTokensUsed ?? root.contextTokensUsed ?? tokensSession,
@@ -175,6 +178,8 @@ function addOrphanSession(context: ScanContext, file: CursorLogFile): void {
175
178
  const root = transcriptSummary(sessionId, files.filter((item) => !item.isSubagent), sessionId, cwd);
176
179
  const child = childSummaries({ sessionId, files, cwd, composers: context.composers });
177
180
  const childLast = child.children.reduce((latest, item) => Math.max(latest, item.lastActivityAt), 0);
181
+ const lastMessageAt = Math.max(root.lastMessageAt ?? 0,
182
+ ...child.summaries.map((item) => item.lastMessageAt ?? 0));
178
183
  const lastActivityAt = Math.max(file.mtimeMs, root.lastActivityAt, childLast);
179
184
  const title = childTitle(context.sidebar.get(sessionId) || root.titleFromUser);
180
185
  const session = aggregateChildThreads({
@@ -185,6 +190,7 @@ function addOrphanSession(context: ScanContext, file: CursorLogFile): void {
185
190
  state: stateFor(lastActivityAt),
186
191
  startedAt: Math.min(file.birthtimeMs || lastActivityAt, root.startedAt || lastActivityAt),
187
192
  lastActivityAt,
193
+ ...(lastMessageAt ? { lastMessageAt } : {}),
188
194
  tokensSession: root.tokensSession,
189
195
  tokensLastTurn: root.tokensLastTurn,
190
196
  contextTokensUsed: root.contextTokensUsed,
@@ -25,6 +25,7 @@ export type CursorLogFile = {
25
25
 
26
26
  export type CursorTranscriptSummary = {
27
27
  lastActivityAt: number;
28
+ lastMessageAt?: number;
28
29
  startedAt: number;
29
30
  files: string[];
30
31
  model?: string;
@@ -196,6 +197,11 @@ function summarizeFile(
196
197
  const item = safeParse(line);
197
198
  if (!item) continue;
198
199
  observeBlocks({ item, file, lineIndex, rootSessionId, cwd, acc });
200
+ if (["user", "assistant"].includes(item.role)
201
+ && textBlocks(item.message?.content).some((text) => !!text.trim())) {
202
+ const at = ts(item.timestamp ?? item.message?.timestamp);
203
+ if (at) acc.lastMessageAt = Math.max(acc.lastMessageAt ?? 0, at);
204
+ }
199
205
  if (!file.isSubagent) {
200
206
  if (!acc.titleFromUser && item.role === "user") {
201
207
  acc.titleFromUser = titleFrom(textBlocks(item.message?.content).join("\n"));
@@ -24,6 +24,7 @@ type ClaudeParseState = {
24
24
  summary?: string;
25
25
  startedAt: number;
26
26
  lastActivityAt: number;
27
+ lastMessageAt: number;
27
28
  tokensSession: number;
28
29
  tokensLastTurn: number;
29
30
  contextTokensUsed?: number;
@@ -53,6 +54,15 @@ function applyClaudeLine(d: any, state: ClaudeParseState): void {
53
54
  if (t) {
54
55
  if (!state.startedAt || t < state.startedAt) state.startedAt = t;
55
56
  if (t > state.lastActivityAt) state.lastActivityAt = t;
57
+ const role = d.message?.role ?? d.type;
58
+ const content = d.message?.content ?? d.content;
59
+ const visible = typeof content === "string" ? content.trim().length > 0
60
+ : Array.isArray(content) && content.some((block: any) =>
61
+ ["text", "image", "image_url", "input_image"].includes(block?.type)
62
+ && (block.type !== "text" || typeof block.text === "string" && !!block.text.trim()));
63
+ if (["user", "assistant"].includes(role) && visible) {
64
+ state.lastMessageAt = Math.max(state.lastMessageAt, t);
65
+ }
56
66
  }
57
67
  const usage = d?.message?.usage;
58
68
  if (usage) {
@@ -117,7 +127,10 @@ export function parseClaudeFile(file: string): {
117
127
  return undefined;
118
128
  }
119
129
  const lines = text.split("\n");
120
- const state: ClaudeParseState = { sessionId: "", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
130
+ const state: ClaudeParseState = {
131
+ sessionId: "", startedAt: 0, lastActivityAt: 0, lastMessageAt: 0,
132
+ tokensSession: 0, tokensLastTurn: 0,
133
+ };
121
134
  for (const line of lines) {
122
135
  if (!line) continue;
123
136
  const d = safeParse(line);
@@ -135,6 +148,7 @@ export function parseClaudeFile(file: string): {
135
148
  state: stateFor(state.lastActivityAt),
136
149
  startedAt: state.startedAt || state.lastActivityAt,
137
150
  lastActivityAt: state.lastActivityAt,
151
+ ...(state.lastMessageAt ? { lastMessageAt: state.lastMessageAt } : {}),
138
152
  tokensSession: state.tokensSession,
139
153
  tokensLastTurn: state.tokensLastTurn,
140
154
  contextTokensUsed: state.contextTokensUsed,
@@ -27,6 +27,7 @@ type CodexParseState = {
27
27
  child?: CodexChildSource;
28
28
  startedAt: number;
29
29
  lastActivityAt: number;
30
+ lastMessageAt: number;
30
31
  tokensSession: number;
31
32
  tokensLastTurn: number;
32
33
  contextTokensUsed?: number;
@@ -40,6 +41,15 @@ function applyCodexLine(d: any, state: CodexParseState): void {
40
41
  if (t) {
41
42
  if (!state.startedAt || t < state.startedAt) state.startedAt = t;
42
43
  if (t > state.lastActivityAt) state.lastActivityAt = t;
44
+ const eventMessage = d.type === "event_msg"
45
+ && ["user_message", "agent_message"].includes(d.payload?.type);
46
+ const responseMessage = d.type === "response_item"
47
+ && d.payload?.type === "message"
48
+ && ["user", "assistant"].includes(d.payload?.role)
49
+ && Array.isArray(d.payload?.content) && d.payload.content.length > 0;
50
+ if (eventMessage || responseMessage) {
51
+ state.lastMessageAt = Math.max(state.lastMessageAt, t);
52
+ }
43
53
  }
44
54
 
45
55
  if (d.type === "session_meta") {
@@ -130,7 +140,7 @@ export function parseCodexFile(file: string): CodexCandidate | undefined {
130
140
  return undefined;
131
141
  }
132
142
  const state: CodexParseState = {
133
- sessionId: "", startedAt: 0, lastActivityAt: 0,
143
+ sessionId: "", startedAt: 0, lastActivityAt: 0, lastMessageAt: 0,
134
144
  tokensSession: 0, tokensLastTurn: 0, workdirs: [],
135
145
  };
136
146
  for (const line of lines) {
@@ -155,6 +165,7 @@ export function parseCodexFile(file: string): CodexCandidate | undefined {
155
165
  state: stateFor(state.lastActivityAt),
156
166
  startedAt: state.startedAt || state.lastActivityAt,
157
167
  lastActivityAt: state.lastActivityAt,
168
+ ...(state.lastMessageAt ? { lastMessageAt: state.lastMessageAt } : {}),
158
169
  tokensSession: state.tokensSession,
159
170
  tokensLastTurn: state.tokensLastTurn,
160
171
  contextTokensUsed: state.contextTokensUsed,
@@ -32,6 +32,7 @@ type ParsedLog = {
32
32
  entries: ActivityEntry[];
33
33
  usage: CapabilityObservation[];
34
34
  firstUser?: string;
35
+ lastMessageAt?: number;
35
36
  tokens: number;
36
37
  lastTurn: number;
37
38
  };
@@ -78,6 +79,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number): Parsed
78
79
  const usage: CapabilityObservation[] = [];
79
80
  const seen = new Set<string>();
80
81
  let firstUser: string | undefined;
82
+ let lastMessageAt = 0;
81
83
  let tokens = 0;
82
84
  let lastTurn = 0;
83
85
  lines.forEach((line, index) => {
@@ -86,11 +88,13 @@ function parseLog(path: string, sessionId: string, fallbackTime: number): Parsed
86
88
  const message = row.message && typeof row.message === "object" ? row.message : row;
87
89
  const role = String(message.role ?? row.role ?? "");
88
90
  if (role === "reasoning" || role === "system") return;
89
- const createdAt = ts(row.timestamp ?? message.timestamp) || fallbackTime + index;
91
+ const messageAt = ts(row.timestamp ?? message.timestamp);
92
+ const createdAt = messageAt || fallbackTime + index;
90
93
  const texts = textBlocks(message.content);
91
94
  if (role === "user") {
92
95
  const text = texts.join("\n").trim();
93
96
  if (text) {
97
+ if (messageAt) lastMessageAt = Math.max(lastMessageAt, messageAt);
94
98
  firstUser ??= text;
95
99
  pushEntry({ out: entries, seen: seen, sessionId: sessionId, kind: "user", text: text, createdAt: createdAt, ordinal: index });
96
100
  tokens += estimateTokens(text);
@@ -103,6 +107,9 @@ function parseLog(path: string, sessionId: string, fallbackTime: number): Parsed
103
107
  pushEntry({ out: entries, seen: seen, sessionId: sessionId, kind: "message", text: text, createdAt: createdAt, ordinal: index * 100 + block });
104
108
  turnTokens += estimateTokens(text);
105
109
  });
110
+ if (texts.some((text) => !!text.trim()) && messageAt) {
111
+ lastMessageAt = Math.max(lastMessageAt, messageAt);
112
+ }
106
113
  const blocks = Array.isArray(message.content) ? message.content : [];
107
114
  blocks.forEach((block: Record<string, unknown>, blockIndex: number) => {
108
115
  if (block?.type !== "tool_use") return;
@@ -122,7 +129,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number): Parsed
122
129
  tokens += turnTokens;
123
130
  if (turnTokens > 0) lastTurn = turnTokens;
124
131
  });
125
- return { entries, usage, firstUser, tokens, lastTurn };
132
+ return { entries, usage, firstUser, lastMessageAt: lastMessageAt || undefined, tokens, lastTurn };
126
133
  }
127
134
 
128
135
  function parseSummary(path: string): Summary | null {
@@ -156,6 +163,7 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
156
163
  sessionId, agent: "grok", title: title?.trim().slice(0, 120), cwd,
157
164
  model: typeof summary.current_model_id === "string" ? summary.current_model_id : undefined,
158
165
  state: stateFor(updatedAt), startedAt, lastActivityAt: updatedAt,
166
+ ...(parsed.lastMessageAt ? { lastMessageAt: parsed.lastMessageAt } : {}),
159
167
  tokensSession: parsed.tokens, tokensLastTurn: parsed.lastTurn,
160
168
  contextTokensUsed: parsed.tokens,
161
169
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.16",
4
+ "version": "0.1.17",
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.23
13
+ npm install -g granttap-mcp@0.8.24
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.23\";\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.24\";\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.23";
10
+ const PACKAGE = "granttap-mcp@0.8.24";
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.23",
3
+ "version": "0.8.24",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,8 @@ export const SessionInfo = z.object({
74
74
  state: SessionState,
75
75
  startedAt: z.number(),
76
76
  lastActivityAt: z.number(),
77
+ /** Timestamp of a visible user or assistant message; absent when no message timestamp is known. */
78
+ lastMessageAt: z.number().optional(),
77
79
  tokensSession: z.number(),
78
80
  tokensLastTurn: z.number(),
79
81
  contextTokensUsed: z.number().optional(),