granttap-mcp 0.8.72 → 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.
- package/apps/bridge/src/monitor/handlers/inbound.ts +1 -1
- package/apps/bridge/src/monitor/handlers/user-message.ts +46 -10
- package/apps/bridge/src/monitor/message-delivery/acceptance.ts +6 -2
- package/apps/bridge/src/monitor/message-delivery/index.ts +2 -1
- package/apps/bridge/src/monitor/support/runtime.ts +3 -1
- 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/index.ts +11 -5
- package/apps/bridge/src/task-dispatch/worker/monitor.ts +29 -7
- package/apps/bridge/src/task-dispatch/worker/readiness.ts +12 -0
- package/apps/bridge/src/task-origins/README.md +22 -0
- package/apps/bridge/src/task-origins/continuation.ts +14 -0
- package/apps/bridge/src/task-origins/index.ts +139 -0
- package/apps/bridge/src/task-origins/projection.ts +20 -0
- package/apps/bridge/src/task-origins/storage.ts +42 -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
- package/packages/protocol/messages/interaction/index.ts +4 -0
- package/packages/protocol/messages/sessions/index.ts +2 -0
- package/packages/protocol/task-dispatch/README.md +7 -0
- package/packages/protocol/task-dispatch/continuation.ts +9 -0
- package/packages/protocol/task-dispatch/index.ts +6 -1
|
@@ -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);
|
|
@@ -16,6 +16,8 @@ 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";
|
|
20
|
+
import { localTaskOrigins, type TaskOriginRegistry, type TaskOriginScope } from '../../task-origins';
|
|
19
21
|
import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
|
|
20
22
|
import { resolveMessageAttachments } from "./receipts";
|
|
21
23
|
import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
|
|
@@ -25,6 +27,7 @@ async function startNewAgentTask(input: {
|
|
|
25
27
|
message: UserMessage;
|
|
26
28
|
say: (text: string, sessionId?: string, wake?: boolean, kind?: "status" | "response") => Promise<unknown>;
|
|
27
29
|
onProcessing?: () => Promise<void>;
|
|
30
|
+
origin?: { registry: TaskOriginRegistry; scope: TaskOriginScope };
|
|
28
31
|
}): Promise<"accepted" | "rejected"> {
|
|
29
32
|
const { client, message, say } = input;
|
|
30
33
|
const agent = message.agent ?? "codex";
|
|
@@ -59,7 +62,19 @@ async function startNewAgentTask(input: {
|
|
|
59
62
|
}
|
|
60
63
|
return "rejected";
|
|
61
64
|
}
|
|
62
|
-
|
|
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
|
+
}
|
|
63
78
|
await say(`Creating a new ${displayName} task…`, undefined, false, "status");
|
|
64
79
|
const create = {
|
|
65
80
|
claude: createClaudeSession,
|
|
@@ -67,13 +82,25 @@ async function startNewAgentTask(input: {
|
|
|
67
82
|
cursor: createCursorSession,
|
|
68
83
|
grok: createGrokSession,
|
|
69
84
|
}[agent];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
};
|
|
77
104
|
const early = await acceptWhenStarted(run, (result) => settle(result, true));
|
|
78
105
|
if (early === "started") return "accepted";
|
|
79
106
|
await settle(early, false);
|
|
@@ -84,6 +111,7 @@ export async function handleUserMessage(
|
|
|
84
111
|
client: RelayClient,
|
|
85
112
|
message: UserMessage,
|
|
86
113
|
onProcessing?: () => Promise<void>,
|
|
114
|
+
peerPublicKey?: string,
|
|
87
115
|
): Promise<"accepted" | "rejected"> {
|
|
88
116
|
const say = (text: string, sessionId?: string, wake = false, kind: "status" | "response" = "response") => {
|
|
89
117
|
const payload = agentEventForUserMessage(message, text, sessionId, kind);
|
|
@@ -93,7 +121,11 @@ export async function handleUserMessage(
|
|
|
93
121
|
: client.send(payload, "phone", options)).catch(() => {});
|
|
94
122
|
};
|
|
95
123
|
if (!message.sessionId) {
|
|
96
|
-
|
|
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 });
|
|
97
129
|
}
|
|
98
130
|
const resolved = resolveMessageAttachments(message, (attachmentId) => takeAttachment(attachmentId, client.room));
|
|
99
131
|
if (!resolved.ok) {
|
|
@@ -104,7 +136,11 @@ export async function handleUserMessage(
|
|
|
104
136
|
}
|
|
105
137
|
const attachments = resolved.attachments;
|
|
106
138
|
|
|
107
|
-
|
|
139
|
+
// The catalog this monitor last published already names the chat. A full
|
|
140
|
+
// provider scan blocks the event loop for seconds on a large transcript set,
|
|
141
|
+
// so it runs only for a chat newer than that catalog.
|
|
142
|
+
const target = publishedSession(client, message.sessionId)
|
|
143
|
+
?? scanSessions().sessions.find((session) => session.sessionId === message.sessionId);
|
|
108
144
|
if (!target) {
|
|
109
145
|
await say("This task is no longer available on the computer.", message.sessionId);
|
|
110
146
|
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;
|
|
@@ -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 =
|
|
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)) {
|
|
@@ -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>();
|
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
|
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()
|
|
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' } : {});
|
|
@@ -9,7 +9,8 @@ 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
|
+
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,7 +18,10 @@ 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);
|
|
20
|
-
|
|
21
|
+
const origins = localTaskOrigins();
|
|
22
|
+
const resolve = (job: Parameters<typeof resolveTaskOriginJob>[1], peer: string) =>
|
|
23
|
+
resolveTaskOriginJob(origins, job, input.config.room, peer);
|
|
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);
|
|
23
27
|
const worker = new TaskDispatchWorker({ inbox, remote: relayTaskJobRemote(input.config),
|
|
@@ -25,17 +29,35 @@ export function startTaskDispatchMonitor(input: {
|
|
|
25
29
|
authorized: peerAllowed,
|
|
26
30
|
onTerminal: job => sender.confirmReceiverOutcome(job),
|
|
27
31
|
onError: () => reportFailure(),
|
|
28
|
-
|
|
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);
|
|
32
|
-
|
|
46
|
+
const observation = { session, generatedAt: catalog?.generatedAt,
|
|
33
47
|
inFlight: isDeliveryInFlight(job.sessionId ?? `__new_${job.operation.agent ?? 'codex'}_task__`),
|
|
34
|
-
paused: !!job.sessionId && isSessionPaused(job.sessionId) }
|
|
48
|
+
paused: !!job.sessionId && isSessionPaused(job.sessionId) };
|
|
49
|
+
const ready = taskDispatchReady(job, observation);
|
|
50
|
+
if (!ready && queuedCatalogRefreshDue(job, observation, refreshedAt)) {
|
|
51
|
+
refreshedAt = Date.now();
|
|
52
|
+
void input.publish().catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
return ready;
|
|
35
55
|
},
|
|
36
|
-
deliver: async (
|
|
56
|
+
deliver: async (original, beforeStart, peer) => {
|
|
57
|
+
const job = resolve(original, peer).job;
|
|
58
|
+
if (!job || job.operation.type === 'user.message.continue') return 'rejected';
|
|
37
59
|
const outcome = job.operation.type === 'user.message'
|
|
38
|
-
? await handleUserMessage(input.client, job.operation, beforeStart)
|
|
60
|
+
? await handleUserMessage(input.client, job.operation, beforeStart, peer)
|
|
39
61
|
: (await handleTaskCreate(input.client, job.operation, beforeStart)).status;
|
|
40
62
|
void input.publish().catch(() => {});
|
|
41
63
|
return outcome;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
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.75
|
|
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.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.
|
|
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
|
@@ -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
|
}
|