granttap-mcp 0.8.72 → 0.8.74
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/apps/bridge/src/monitor/handlers/user-message.ts +6 -1
- package/apps/bridge/src/monitor/message-delivery/acceptance.ts +6 -2
- package/apps/bridge/src/sessions/cursor/README.md +4 -0
- package/apps/bridge/src/sessions/cursor/activity.ts +13 -3
- package/apps/bridge/src/sessions/cursor/attention/README.md +12 -0
- package/apps/bridge/src/sessions/cursor/attention/index.ts +3 -0
- package/apps/bridge/src/sessions/cursor/attention/native.ts +48 -0
- package/apps/bridge/src/sessions/cursor/attention/presentation.ts +32 -0
- package/apps/bridge/src/sessions/cursor/catalog.ts +1 -0
- package/apps/bridge/src/sessions/scan/claude/parse.ts +16 -3
- package/apps/bridge/src/sessions/scan/claude/shared.ts +2 -0
- package/apps/bridge/src/task-dispatch/worker/monitor.ts +10 -4
- package/apps/bridge/src/task-dispatch/worker/readiness.ts +12 -0
- 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
|
@@ -16,6 +16,7 @@ import { noteDeliveredRun } from "../../mesh/delivery/run-digest";
|
|
|
16
16
|
import { evaluateCreateTask } from "../../mesh/tasks/create-task";
|
|
17
17
|
import { sendSessionPayload } from "../../host/session-keys";
|
|
18
18
|
import { scanSessions } from "../../sessions";
|
|
19
|
+
import { publishedSession } from "../../session-catalog";
|
|
19
20
|
import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
|
|
20
21
|
import { resolveMessageAttachments } from "./receipts";
|
|
21
22
|
import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
|
|
@@ -104,7 +105,11 @@ export async function handleUserMessage(
|
|
|
104
105
|
}
|
|
105
106
|
const attachments = resolved.attachments;
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
// The catalog this monitor last published already names the chat. A full
|
|
109
|
+
// provider scan blocks the event loop for seconds on a large transcript set,
|
|
110
|
+
// so it runs only for a chat newer than that catalog.
|
|
111
|
+
const target = publishedSession(client, message.sessionId)
|
|
112
|
+
?? scanSessions().sessions.find((session) => session.sessionId === message.sessionId);
|
|
108
113
|
if (!target) {
|
|
109
114
|
await say("This task is no longer available on the computer.", message.sessionId);
|
|
110
115
|
return "rejected";
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* its answer — or the reason it stopped — reaches the chat when it ends.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Long enough for a provider CLI to refuse at once (missing chat, bad flag,
|
|
15
|
+
* not signed in), short enough that the message leaves the phone's queue
|
|
16
|
+
* while the person is still looking at it.
|
|
17
|
+
*/
|
|
18
|
+
export const ACCEPTANCE_WINDOW_MS = 3_000;
|
|
15
19
|
|
|
16
20
|
/** A safety bound for an unattended turn; the person stops a run with Stop, not with a deadline. */
|
|
17
21
|
export const PHONE_RUN_LIMIT_MS = 6 * 60 * 60_000;
|
|
@@ -7,3 +7,7 @@ public entry point. Characterization coverage lives in `tests/sessions.test.ts`,
|
|
|
7
7
|
|
|
8
8
|
License: this module is distributed under the Apache License 2.0
|
|
9
9
|
in the repository-root `LICENSE` file.
|
|
10
|
+
|
|
11
|
+
`attention/` supplements JSONL messages with the native composer's visible
|
|
12
|
+
question cards and warnings. Missing hooks or a text-only transcript must not
|
|
13
|
+
hide the prompt or choices. Native storage is read-only and bounded.
|
|
@@ -2,10 +2,11 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import type { ActivityEntry, ChildThreadInfo, SessionInfo } from "../../../../../packages/protocol/schema";
|
|
3
3
|
import { classifyTool, estimateTokens, pushEntry, toolSummary } from "../support/activity-helpers";
|
|
4
4
|
import { childEntryFields } from "../support/child-threads";
|
|
5
|
-
import { safeParse, stateFor } from "../support/common";
|
|
5
|
+
import { safeParse, stateFor, ts } from "../support/common";
|
|
6
6
|
import type { CapabilityObservation } from "../telemetry";
|
|
7
7
|
import { cursorFiles, cursorUsage, scanCursor } from "./scan";
|
|
8
8
|
import { textBlocks } from "./transcripts";
|
|
9
|
+
import { cursorNativeAttention, cursorQuestionText, cursorNoticeText } from "./attention";
|
|
9
10
|
|
|
10
11
|
function appendFileActivity(input: {
|
|
11
12
|
out: ActivityEntry[];
|
|
@@ -31,8 +32,14 @@ function appendFileActivity(input: {
|
|
|
31
32
|
lines.forEach((line, index) => {
|
|
32
33
|
const item = safeParse(line);
|
|
33
34
|
if (!item) return;
|
|
34
|
-
const createdAt = (
|
|
35
|
+
const createdAt = ts(item.timestamp ?? item.message?.timestamp)
|
|
35
36
|
|| baseTime + (indexOffset + index) * 1_000;
|
|
37
|
+
const notices = [item, ...(Array.isArray(item.message?.content) ? item.message.content : [])];
|
|
38
|
+
notices.forEach((row, ordinal) => {
|
|
39
|
+
const notice = cursorNoticeText(row);
|
|
40
|
+
if (notice) pushEntry({ fullText, out, seen, sessionId, kind: "status", text: notice,
|
|
41
|
+
createdAt, ordinal: (indexOffset + index) * 100 + ordinal, extras: childFields });
|
|
42
|
+
});
|
|
36
43
|
if (item.role === "user") {
|
|
37
44
|
const text = textBlocks(item.message?.content).join("\n").trim();
|
|
38
45
|
if (!text) return;
|
|
@@ -53,7 +60,7 @@ function appendFileActivity(input: {
|
|
|
53
60
|
if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
54
61
|
pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: "message", text: block.text, createdAt: createdAt, ordinal: sequence, extras: childFields, idOverride: entryId(createdAt, sequence) });
|
|
55
62
|
} else if (block?.type === "tool_use") {
|
|
56
|
-
pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: "tool", text: toolSummary(block.name, block.input), createdAt: createdAt, ordinal: sequence, extras: {
|
|
63
|
+
pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: cursorQuestionText(block.name, block.input) ? "message" : "tool", text: cursorQuestionText(block.name, block.input) ?? toolSummary(block.name, block.input), createdAt: createdAt, ordinal: sequence, extras: {
|
|
57
64
|
...childFields,
|
|
58
65
|
...classifyTool(block.name, block.input),
|
|
59
66
|
estimatedContextTokens: estimateTokens(block.input),
|
|
@@ -85,6 +92,9 @@ export function cursorActivity(session: SessionInfo, fullText = false): Activity
|
|
|
85
92
|
baseTime: base, indexOffset: index * 10_000, child,
|
|
86
93
|
});
|
|
87
94
|
});
|
|
95
|
+
for (const entry of cursorNativeAttention(session)) {
|
|
96
|
+
if (!out.some(existing => existing.text === entry.text)) out.push(entry);
|
|
97
|
+
}
|
|
88
98
|
return out.sort((a, b) => a.createdAt - b.createdAt);
|
|
89
99
|
}
|
|
90
100
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Cursor attention
|
|
2
|
+
|
|
3
|
+
Public entries: `cursorQuestionText`, `cursorNoticeText`, and `cursorNativeAttention`.
|
|
4
|
+
They project user-visible Cursor questions, their choices, and warnings into the
|
|
5
|
+
existing chat transcript. Cursor sometimes keeps question cards in its local
|
|
6
|
+
composer database without writing them to the JSONL transcript. The native reader
|
|
7
|
+
uses read-only indexed lookups of the exact composer's most recent 128 bubbles,
|
|
8
|
+
with a one-second timeout and bounded results. It never reads thought blocks,
|
|
9
|
+
changes native answers, or represents a displayed question as answered.
|
|
10
|
+
|
|
11
|
+
Tests exercise the public projection and an isolated SQLite database. Distributed
|
|
12
|
+
under the repository Apache License 2.0.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ActivityEntry, SessionInfo } from '../../../../../../packages/protocol/schema';
|
|
6
|
+
import { cursorQuestionText, cursorNoticeText } from './presentation';
|
|
7
|
+
|
|
8
|
+
type NativeRow = { id: string; at?: string | number; name?: string; params?: unknown; warning?: unknown };
|
|
9
|
+
const sql = (value: string) => "'" + value.replaceAll("'", "''") + "'";
|
|
10
|
+
|
|
11
|
+
function decoded(value: unknown): unknown {
|
|
12
|
+
if (typeof value !== 'string') return value;
|
|
13
|
+
try { return JSON.parse(value); } catch { return undefined; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Exact indexed composer/bubble reads. No database scan and no writes to Cursor's state. */
|
|
17
|
+
export function cursorNativeAttention(session: SessionInfo, db = process.env.GRANTTAP_CURSOR_STATE_DB
|
|
18
|
+
?? join(homedir(), 'Library/Application Support/Cursor/User/globalStorage/state.vscdb')): ActivityEntry[] {
|
|
19
|
+
if (!existsSync(db) || !session.sessionId || session.sessionId.length > 256) return [];
|
|
20
|
+
try {
|
|
21
|
+
const query = `WITH composer AS (SELECT value FROM cursorDiskKV WHERE key=${sql('composerData:' + session.sessionId)}),
|
|
22
|
+
headers AS (SELECT json_extract(j.value,'$.bubbleId') AS id, json_extract(j.value,'$.createdAt') AS at
|
|
23
|
+
FROM composer, json_each(composer.value,'$.fullConversationHeadersOnly') j
|
|
24
|
+
ORDER BY CAST(j.key AS INTEGER) DESC LIMIT 128)
|
|
25
|
+
SELECT h.id, coalesce(json_extract(b.value,'$.createdAt'),h.at) AS at,
|
|
26
|
+
json_extract(b.value,'$.toolFormerData.name') AS name,
|
|
27
|
+
CASE WHEN lower(json_extract(b.value,'$.toolFormerData.name')) IN ('ask_question','askquestion','askuserquestion')
|
|
28
|
+
THEN json_extract(b.value,'$.toolFormerData.params') END AS params,
|
|
29
|
+
coalesce(json_extract(b.value,'$.errorDetails.error.details.detail'),
|
|
30
|
+
json_extract(b.value,'$.errorDetails.error.details.title'),
|
|
31
|
+
json_extract(b.value,'$.errorDetails.message'),json_extract(b.value,'$.warning.message')) AS warning
|
|
32
|
+
FROM headers h JOIN cursorDiskKV b ON b.key=${sql('bubbleId:' + session.sessionId + ':')} || h.id;`;
|
|
33
|
+
const raw = execFileSync('sqlite3', ['-readonly', '-json', db, query], {
|
|
34
|
+
encoding: 'utf8', timeout: 1_000, maxBuffer: 2 * 1_024 * 1_024, stdio: ['ignore', 'pipe', 'ignore'],
|
|
35
|
+
});
|
|
36
|
+
const rows: NativeRow[] = raw.trim() ? JSON.parse(raw) : [];
|
|
37
|
+
return rows.flatMap(row => {
|
|
38
|
+
const question = cursorQuestionText(row.name, decoded(row.params));
|
|
39
|
+
const notice = cursorNoticeText({ type: 'warning', message: row.warning });
|
|
40
|
+
const text = question ?? notice;
|
|
41
|
+
if (!text || typeof row.id !== 'string' || row.id.length > 256) return [];
|
|
42
|
+
const at = typeof row.at === 'number' ? row.at : Date.parse(row.at ?? '');
|
|
43
|
+
return [{ id: `cursor-attention:${session.sessionId}:${row.id}`,
|
|
44
|
+
kind: question ? 'message' as const : 'status' as const, text,
|
|
45
|
+
createdAt: Number.isFinite(at) ? at : session.lastActivityAt }];
|
|
46
|
+
}).sort((a, b) => a.createdAt - b.createdAt);
|
|
47
|
+
} catch { return []; }
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const text = z.string().trim().min(1).max(2_000);
|
|
4
|
+
const option = z.union([z.string().trim().min(1).max(512), z.object({ label: z.string().trim().min(1).max(512) })]);
|
|
5
|
+
const questions = z.object({ title: z.string().max(160).optional(), questions: z.array(z.object({
|
|
6
|
+
prompt: text.optional(), question: text.optional(), options: z.array(option).max(8).optional(),
|
|
7
|
+
})).min(1).max(4) });
|
|
8
|
+
|
|
9
|
+
/** Only the visible question and options; native reasoning and raw tool inputs stay private. */
|
|
10
|
+
export function cursorQuestionText(name: unknown, input: unknown): string | undefined {
|
|
11
|
+
if (typeof name !== 'string' || !['askquestion', 'ask_question', 'askuserquestion'].includes(name.toLowerCase())) return;
|
|
12
|
+
const parsed = questions.safeParse(input);
|
|
13
|
+
if (!parsed.success || parsed.data.questions.some(q => !q.prompt && !q.question)) return;
|
|
14
|
+
const parts = parsed.data.questions.map(q => [q.prompt ?? q.question!,
|
|
15
|
+
...(q.options ?? []).map(o => '• ' + (typeof o === 'string' ? o : o.label))].join('\n'));
|
|
16
|
+
return [parsed.data.title, ...parts].filter(Boolean).join('\n\n').slice(0, 16_384);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Explicit user-visible notices only, never an arbitrary diagnostic or thought block. */
|
|
20
|
+
export function cursorNoticeText(item: unknown): string | undefined {
|
|
21
|
+
if (!item || typeof item !== 'object') return;
|
|
22
|
+
const row = item as Record<string, unknown>;
|
|
23
|
+
if (row.type === 'tool_result' && row.is_error === true) {
|
|
24
|
+
const content = typeof row.content === 'string' ? row.content : Array.isArray(row.content)
|
|
25
|
+
? row.content.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n') : undefined;
|
|
26
|
+
return typeof content === 'string' && content.trim() ? content.trim().slice(0, 4_000) : undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!['warning', 'error'].includes(String(row.type))) return;
|
|
29
|
+
const message = typeof row.message === 'string' ? row.message
|
|
30
|
+
: row.message && typeof row.message === 'object' ? (row.message as Record<string, unknown>).content : row.text;
|
|
31
|
+
return typeof message === 'string' && message.trim() ? message.trim().slice(0, 4_000) : undefined;
|
|
32
|
+
}
|
|
@@ -258,6 +258,7 @@ export function composerActivityAt(composer: ComposerRow): number {
|
|
|
258
258
|
|
|
259
259
|
export function composerState(status: string | undefined, lastActivityAt: number): SessionState {
|
|
260
260
|
const value = (status ?? "").toLowerCase();
|
|
261
|
+
if (["waiting", "waiting_for_user", "awaiting_user_response"].includes(value)) return "waiting";
|
|
261
262
|
if (["generating", "continuing", "applying", "running", "in_progress", "active"].includes(value)) {
|
|
262
263
|
return "working";
|
|
263
264
|
}
|
|
@@ -31,9 +31,21 @@ type ClaudeParseState = {
|
|
|
31
31
|
tokensLastTurn: number;
|
|
32
32
|
contextTokensUsed?: number;
|
|
33
33
|
repositoryPaths: string[];
|
|
34
|
+
turnEnded: boolean;
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
/** Stop reasons after which Claude Code waits for the person's next prompt. */
|
|
38
|
+
const FINISHED_TURN = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
|
39
|
+
|
|
40
|
+
/** Metadata, progress and subagent lines do not start or end the chat's own turn. */
|
|
41
|
+
function noteTurnBoundary(d: any, state: ClaudeParseState): void {
|
|
42
|
+
if (d.isSidechain === true) return;
|
|
43
|
+
if (d.type === "user" || d.type === "queue-operation") state.turnEnded = false;
|
|
44
|
+
else if (d.type === "assistant") state.turnEnded = FINISHED_TURN.has(d.message?.stop_reason);
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
function applyClaudeLine(d: any, state: ClaudeParseState): void {
|
|
48
|
+
noteTurnBoundary(d, state);
|
|
37
49
|
state.repositoryPaths.push(...claudeRepositoryPaths(d));
|
|
38
50
|
state.repositoryPaths = state.repositoryPaths.slice(-64);
|
|
39
51
|
if (d.sessionId && !state.sessionId) state.sessionId = String(d.sessionId);
|
|
@@ -119,7 +131,7 @@ export function parseClaudeFile(file: string): {
|
|
|
119
131
|
state:
|
|
120
132
|
cached.session.childThreads?.some((child) => stateFor(child.lastActivityAt) === "working")
|
|
121
133
|
? "working" as const
|
|
122
|
-
: stateFor(cached.session.lastActivityAt),
|
|
134
|
+
: cached.turnEnded ? "idle" as const : stateFor(cached.session.lastActivityAt),
|
|
123
135
|
childThreads: cached.session.childThreads?.map((child) => ({
|
|
124
136
|
...child,
|
|
125
137
|
state: stateFor(child.lastActivityAt),
|
|
@@ -136,7 +148,7 @@ export function parseClaudeFile(file: string): {
|
|
|
136
148
|
const lines = text.split("\n");
|
|
137
149
|
const state: ClaudeParseState = {
|
|
138
150
|
sessionId: "", startedAt: 0, lastActivityAt: 0, lastMessageAt: 0,
|
|
139
|
-
tokensSession: 0, tokensLastTurn: 0, repositoryPaths: [],
|
|
151
|
+
tokensSession: 0, tokensLastTurn: 0, repositoryPaths: [], turnEnded: false,
|
|
140
152
|
};
|
|
141
153
|
for (const line of lines) {
|
|
142
154
|
if (!line) continue;
|
|
@@ -153,7 +165,7 @@ export function parseClaudeFile(file: string): {
|
|
|
153
165
|
branch: state.branch,
|
|
154
166
|
model: state.model,
|
|
155
167
|
summary: state.summary,
|
|
156
|
-
state: stateFor(state.lastActivityAt),
|
|
168
|
+
state: state.turnEnded ? "idle" : stateFor(state.lastActivityAt),
|
|
157
169
|
startedAt: state.startedAt || state.lastActivityAt,
|
|
158
170
|
lastActivityAt: state.lastActivityAt,
|
|
159
171
|
...(state.lastMessageAt ? { lastMessageAt: state.lastMessageAt } : {}),
|
|
@@ -182,6 +194,7 @@ export function parseClaudeFile(file: string): {
|
|
|
182
194
|
session,
|
|
183
195
|
tokensSession: session.tokensSession,
|
|
184
196
|
observations,
|
|
197
|
+
turnEnded: state.turnEnded,
|
|
185
198
|
});
|
|
186
199
|
return { session, tokensSession: session.tokensSession, childPaths: actualChildPaths };
|
|
187
200
|
}
|
|
@@ -51,6 +51,8 @@ export type CachedClaudeSummary = {
|
|
|
51
51
|
session: SessionInfo;
|
|
52
52
|
tokensSession: number;
|
|
53
53
|
observations: CapabilityObservation[];
|
|
54
|
+
/** The chat's own last turn ended; recent writes no longer mean it is working. */
|
|
55
|
+
turnEnded?: boolean;
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
export const claudeSummaryCache = new Map<string, CachedClaudeSummary>();
|
|
@@ -9,7 +9,7 @@ import { TaskDispatchInbox } from '../index';
|
|
|
9
9
|
import { TaskDispatchWorker } from './index';
|
|
10
10
|
import { relayTaskJobRemote } from './remote';
|
|
11
11
|
import { NativeTaskQueueSender } from '../sender';
|
|
12
|
-
import { taskDispatchReady } from './readiness';
|
|
12
|
+
import { queuedCatalogRefreshDue, taskDispatchReady } from './readiness';
|
|
13
13
|
|
|
14
14
|
/** Only the persistent monitor advertises durable submission capability. */
|
|
15
15
|
export function startTaskDispatchMonitor(input: {
|
|
@@ -17,7 +17,7 @@ export function startTaskDispatchMonitor(input: {
|
|
|
17
17
|
peerAllowed: (peer: string) => boolean; publish: () => Promise<void>;
|
|
18
18
|
}): () => void {
|
|
19
19
|
const inbox = new TaskDispatchInbox(join(configDir(), 'task-dispatch'), input.config.room);
|
|
20
|
-
let stopped = false, reportedAt = 0, maintainedAt = 0;
|
|
20
|
+
let stopped = false, reportedAt = 0, maintainedAt = 0, refreshedAt = 0;
|
|
21
21
|
const sender = new NativeTaskQueueSender({ root: join(configDir(), 'native-task-queue'), config: input.config });
|
|
22
22
|
const peerAllowed = (peer: string) => peer === input.config.myPublicKey || input.peerAllowed(peer);
|
|
23
23
|
const worker = new TaskDispatchWorker({ inbox, remote: relayTaskJobRemote(input.config),
|
|
@@ -29,9 +29,15 @@ export function startTaskDispatchMonitor(input: {
|
|
|
29
29
|
const catalog = publishedCatalog(input.client);
|
|
30
30
|
const session = [...(catalog?.sessions ?? []), ...(catalog?.history ?? [])]
|
|
31
31
|
.find(item => item.sessionId === job.sessionId);
|
|
32
|
-
|
|
32
|
+
const observation = { session, generatedAt: catalog?.generatedAt,
|
|
33
33
|
inFlight: isDeliveryInFlight(job.sessionId ?? `__new_${job.operation.agent ?? 'codex'}_task__`),
|
|
34
|
-
paused: !!job.sessionId && isSessionPaused(job.sessionId) }
|
|
34
|
+
paused: !!job.sessionId && isSessionPaused(job.sessionId) };
|
|
35
|
+
const ready = taskDispatchReady(job, observation);
|
|
36
|
+
if (!ready && queuedCatalogRefreshDue(job, observation, refreshedAt)) {
|
|
37
|
+
refreshedAt = Date.now();
|
|
38
|
+
void input.publish().catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
return ready;
|
|
35
41
|
},
|
|
36
42
|
deliver: async (job, beforeStart) => {
|
|
37
43
|
const outcome = job.operation.type === 'user.message'
|
|
@@ -12,3 +12,15 @@ export function taskDispatchReady(job: TaskDispatch, observation: {
|
|
|
12
12
|
// which returns a definite rejection rather than keeping a lost task forever.
|
|
13
13
|
return !observation.session || observation.session.state === 'idle';
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/** The catalog loop runs every 30 s; a waiting queued message should not. */
|
|
17
|
+
export const QUEUED_CATALOG_REFRESH_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
/** One rate-limited catalog refresh lets a queued job see its chat go idle sooner. */
|
|
20
|
+
export function queuedCatalogRefreshDue(job: TaskDispatch, observation: {
|
|
21
|
+
generatedAt?: number; inFlight: boolean; paused: boolean;
|
|
22
|
+
}, lastRefreshAt: number, now = Date.now()): boolean {
|
|
23
|
+
if (!job.sessionId || job.deliveryMode === 'immediate' || observation.inFlight || observation.paused) return false;
|
|
24
|
+
return now - (observation.generatedAt ?? 0) >= QUEUED_CATALOG_REFRESH_MS
|
|
25
|
+
&& now - lastRefreshAt >= QUEUED_CATALOG_REFRESH_MS;
|
|
26
|
+
}
|
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.74
|
|
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.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"
|
|
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.74";
|
|
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;
|