granttap-mcp 0.8.96 → 0.8.98
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/delivery/index.ts +38 -6
- package/apps/bridge/src/mesh/admin/convergence.ts +7 -1
- package/apps/bridge/src/mesh/catalog/index.ts +2 -0
- package/apps/bridge/src/mesh/runtime/execution-sweep.ts +2 -0
- package/apps/bridge/src/mesh/snapshot/merge.ts +3 -2
- package/apps/bridge/src/mesh/store/index.ts +2 -2
- package/apps/bridge/src/mesh/store/receipts/execution-merge.ts +10 -0
- package/apps/bridge/src/mesh/store/receipts/prepared-availability.ts +55 -0
- package/apps/bridge/src/mesh/store/sync.ts +4 -3
- package/apps/bridge/src/monitor/handlers/inbound.ts +3 -0
- package/apps/bridge/src/monitor/handlers/receipts.ts +5 -1
- package/apps/bridge/src/monitor/handlers/user-message.ts +3 -1
- package/apps/bridge/src/monitor/message-delivery/README.md +10 -0
- package/apps/bridge/src/monitor/message-delivery/activity.ts +14 -0
- package/apps/bridge/src/monitor/message-delivery/index.ts +6 -3
- package/apps/bridge/src/monitor/message-delivery/status-query.ts +12 -0
- package/apps/bridge/src/providers/grok-activity/README.md +13 -0
- package/apps/bridge/src/providers/grok-activity/index.ts +64 -0
- package/apps/bridge/src/reply/index.ts +11 -4
- package/apps/bridge/src/reply/payload/types.ts +2 -0
- package/apps/bridge/src/reply/process/index.ts +13 -1
- package/apps/bridge/src/reply/process/provider-headless.ts +9 -2
- package/apps/bridge/src/sessions/scan/grok.ts +3 -1
- package/apps/bridge/src/task-transfer/README.md +11 -0
- package/apps/bridge/src/task-transfer/index.ts +19 -2
- 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/core/outbound-scheduler/index.ts +3 -3
- package/packages/core/relay-client.ts +3 -2
- package/packages/core/socket-lifecycle/README.md +4 -0
- package/packages/protocol/messages/interaction/index.ts +8 -0
- package/packages/protocol/schema.ts +2 -0
|
@@ -12,10 +12,14 @@ type DeliveryRecord = {
|
|
|
12
12
|
state: "processing" | "completed";
|
|
13
13
|
updatedAt: number;
|
|
14
14
|
processingAcknowledged?: boolean;
|
|
15
|
+
agentActivityAt?: number;
|
|
15
16
|
rejectedError?: string;
|
|
17
|
+
scope?: DeliveryScope;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
export type
|
|
20
|
+
export type DeliveryScope = { room: string; peerPublicKey: string; sessionId: string };
|
|
21
|
+
|
|
22
|
+
export type DeliveryStart = "started" | "processing" | "completed" | "foreign";
|
|
19
23
|
|
|
20
24
|
export function deliveryLedgerPath(): string {
|
|
21
25
|
return join(configDir(), "delivery-ledger.json");
|
|
@@ -62,38 +66,56 @@ function save(records: Record<string, DeliveryRecord>, now: number): void {
|
|
|
62
66
|
export function acknowledgeProcessingDelivery(messageId: string, now = Date.now()): void {
|
|
63
67
|
const records = load();
|
|
64
68
|
if (records[messageId]?.state !== "processing") return;
|
|
65
|
-
records[messageId] = { state: "processing", updatedAt: now, processingAcknowledged: true };
|
|
69
|
+
records[messageId] = { ...records[messageId], state: "processing", updatedAt: now, processingAcknowledged: true };
|
|
66
70
|
save(records, now);
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** Provider output, rather than process creation, confirms the original operation started. */
|
|
74
|
+
export function acknowledgeAgentActivity(messageId: string, at = Date.now(), scope?: DeliveryScope): number | undefined {
|
|
75
|
+
const records = load();
|
|
76
|
+
const record = records[messageId] ?? (scope
|
|
77
|
+
? { state: 'completed' as const, updatedAt: at, processingAcknowledged: true, scope } : undefined);
|
|
78
|
+
if (!record || record.rejectedError || !Number.isFinite(at) || at <= 0
|
|
79
|
+
|| records[messageId] && scope && !record.scope
|
|
80
|
+
|| scope && record.scope && (scope.room !== record.scope.room
|
|
81
|
+
|| scope.peerPublicKey !== record.scope.peerPublicKey || scope.sessionId !== record.scope.sessionId)) return undefined;
|
|
82
|
+
if (record.agentActivityAt == null) {
|
|
83
|
+
records[messageId] = { ...record, agentActivityAt: at };
|
|
84
|
+
save(records, Date.now());
|
|
85
|
+
}
|
|
86
|
+
return records[messageId]?.agentActivityAt;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export function processingDeliveryAcknowledged(messageId: string): boolean {
|
|
70
90
|
const record = load()[messageId];
|
|
71
91
|
return record?.state === "processing" && record.processingAcknowledged === true;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
/** Acquire a durable processing lease before invoking an agent. */
|
|
75
|
-
export function beginDelivery(messageId: string, now = Date.now()): DeliveryStart {
|
|
95
|
+
export function beginDelivery(messageId: string, now = Date.now(), scope?: DeliveryScope): DeliveryStart {
|
|
76
96
|
const records = load();
|
|
77
97
|
const current = records[messageId];
|
|
98
|
+
if (current?.scope && (!scope || current.scope.room !== scope.room
|
|
99
|
+
|| current.scope.peerPublicKey !== scope.peerPublicKey || current.scope.sessionId !== scope.sessionId)) return 'foreign';
|
|
78
100
|
if (current?.state === "completed" && current.updatedAt >= now - MAX_AGE_MS) return "completed";
|
|
79
101
|
if (current?.state === "processing" && current.updatedAt >= now - PROCESSING_LEASE_MS) {
|
|
80
102
|
return "processing";
|
|
81
103
|
}
|
|
82
|
-
records[messageId] = { state: "processing", updatedAt: now };
|
|
104
|
+
records[messageId] = { state: "processing", updatedAt: now, scope };
|
|
83
105
|
save(records, now);
|
|
84
106
|
return "started";
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
export function completeDelivery(messageId: string, now = Date.now()): void {
|
|
88
110
|
const records = load();
|
|
89
|
-
records[messageId] = { state: "completed", updatedAt: now };
|
|
111
|
+
records[messageId] = { ...records[messageId], state: "completed", updatedAt: now };
|
|
90
112
|
save(records, now);
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
/** A late provider refusal remains terminal across reconnects and replayed boxes. */
|
|
94
116
|
export function rejectCompletedDelivery(messageId: string, error: string, now = Date.now()): void {
|
|
95
117
|
const records = load();
|
|
96
|
-
records[messageId] = { state: 'completed', updatedAt: now, rejectedError: error.slice(0,
|
|
118
|
+
records[messageId] = { ...records[messageId], state: 'completed', updatedAt: now, rejectedError: error.slice(0, 500) };
|
|
97
119
|
save(records, now);
|
|
98
120
|
}
|
|
99
121
|
|
|
@@ -103,6 +125,16 @@ export function completedDeliveryFailure(messageId: string): string | undefined
|
|
|
103
125
|
? record.rejectedError : undefined;
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
/** Receipt replay is scoped to the original authenticated controller and route. */
|
|
129
|
+
export function deliveryReceiptStatus(messageId: string, scope: DeliveryScope, now = Date.now()) {
|
|
130
|
+
const record = load()[messageId], saved = record?.scope;
|
|
131
|
+
if (!saved || saved.room !== scope.room || saved.peerPublicKey !== scope.peerPublicKey
|
|
132
|
+
|| saved.sessionId !== scope.sessionId || record.updatedAt < now - MAX_AGE_MS
|
|
133
|
+
|| record.updatedAt > now || record.state === 'processing' && !record.processingAcknowledged) return undefined;
|
|
134
|
+
return { status: record.rejectedError ? 'rejected' as const : 'accepted' as const,
|
|
135
|
+
error: record.rejectedError, agentActivityAt: record.agentActivityAt };
|
|
136
|
+
}
|
|
137
|
+
|
|
106
138
|
/** Release a failed attempt so a relay retry can safely try again. */
|
|
107
139
|
export function abandonDelivery(messageId: string, now = Date.now()): void {
|
|
108
140
|
const records = load();
|
|
@@ -50,11 +50,14 @@ function taskOrderKey(task: TaskValue): string {
|
|
|
50
50
|
export function preferExecution(
|
|
51
51
|
current: ExecutionValue,
|
|
52
52
|
incoming: ExecutionValue,
|
|
53
|
+
preparedOwner = false,
|
|
53
54
|
): ExecutionValue {
|
|
54
55
|
if ((current.endedAt != null) !== (incoming.endedAt != null)) {
|
|
55
56
|
const ended = current.endedAt != null ? current : incoming;
|
|
56
57
|
const live = current.endedAt != null ? incoming : current;
|
|
57
58
|
if (live.continuedAt != null && live.continuedAt > ended.endedAt!) return live;
|
|
59
|
+
if (preparedOwner && /^transfer-[a-f0-9]{48}$/.test(live.sessionId)
|
|
60
|
+
&& (live.updatedAt ?? live.startedAt) > ended.endedAt!) return live;
|
|
58
61
|
return { ...live, endedAt: ended.endedAt };
|
|
59
62
|
}
|
|
60
63
|
const currentAt = current.updatedAt ?? current.startedAt;
|
|
@@ -78,7 +81,10 @@ export function mayOwnTask(
|
|
|
78
81
|
sessionId: string,
|
|
79
82
|
executions: readonly ExecutionValue[],
|
|
80
83
|
): boolean {
|
|
81
|
-
if (task.ownerSessionId
|
|
84
|
+
if (task.ownerSessionId === sessionId) return true;
|
|
85
|
+
const candidate = executions.find(item => item.taskId === task.taskId && item.sessionId === sessionId);
|
|
86
|
+
if (candidate?.endedAt != null) return false;
|
|
87
|
+
if (task.ownerSessionId == null) return true;
|
|
82
88
|
const owner = executions.find((item) =>
|
|
83
89
|
item.taskId === task.taskId && item.sessionId === task.ownerSessionId);
|
|
84
90
|
return owner == null || owner.endedAt != null;
|
|
@@ -19,6 +19,7 @@ import { readIntegrationMap } from "../observed/integration-map";
|
|
|
19
19
|
import type { MeshStore } from "../store";
|
|
20
20
|
import { queueProjectBindingSync } from "../../engine/runtime/engine-projects";
|
|
21
21
|
import { isCursorTaskCloneId } from "../../sessions/cursor/catalog";
|
|
22
|
+
import { localTaskTransfers } from '../../task-transfer';
|
|
22
23
|
export { inspectRepositoryAsync } from "./repository-inspection";
|
|
23
24
|
|
|
24
25
|
export type RepositoryFacts = {
|
|
@@ -122,6 +123,7 @@ export function linkSessionsToProjects(
|
|
|
122
123
|
inspect: (cwd: string) => RepositoryFacts = inspectRepository,
|
|
123
124
|
formerNames: string[] = formerComputerNames(),
|
|
124
125
|
): SessionInfo[] {
|
|
126
|
+
localTaskTransfers().reconcileCatalogOwnership(store, computerId);
|
|
125
127
|
// Records kept under a name this computer used to go by are this computer's
|
|
126
128
|
// leftovers, not a second machine: their executions are over and their
|
|
127
129
|
// bindings unavailable.
|
|
@@ -27,6 +27,8 @@ export function closeVanished(
|
|
|
27
27
|
if (execution.computerId !== computerId || execution.endedAt != null) continue;
|
|
28
28
|
// A provider cloud run is never a local chat, so its absence here says nothing.
|
|
29
29
|
if (execution.external) continue;
|
|
30
|
+
// Prepared aliases are owned by the durable transfer catalog, not a native CLI scan.
|
|
31
|
+
if (/^transfer-[a-f0-9]{48}$/.test(execution.sessionId)) continue;
|
|
30
32
|
if (!scannedProviders.has(execution.provider)) continue;
|
|
31
33
|
if (liveSessionIds.has(execution.sessionId)) continue;
|
|
32
34
|
execution.endedAt = endedAt;
|
|
@@ -4,6 +4,7 @@ import { upsertBinding } from "../catalog/binding-state";
|
|
|
4
4
|
import { integrationPeerKey } from "../handoff/other-side";
|
|
5
5
|
import { mergeBy, mergeWith } from "../store/support";
|
|
6
6
|
import { MAX_STORE_PEERS, type StoreState } from "../store/state";
|
|
7
|
+
import { preferScopedExecution } from '../store/receipts/execution-merge';
|
|
7
8
|
|
|
8
9
|
export function mergeSnapshotState(state: StoreState, input: SnapshotValue): void {
|
|
9
10
|
const snapshot = MeshSnapshot.parse(input);
|
|
@@ -25,11 +26,12 @@ export function mergeSnapshotState(state: StoreState, input: SnapshotValue): voi
|
|
|
25
26
|
candidate.peers = mergeBy(candidate.peers, snapshot.peers ?? [], integrationPeerKey)
|
|
26
27
|
.slice(-MAX_STORE_PEERS);
|
|
27
28
|
candidate.tasks = mergeWith(candidate.tasks, snapshot.tasks, (item) => item.taskId, preferTask);
|
|
29
|
+
candidate.events = mergeBy(candidate.events, snapshot.events, (item) => item.eventId).slice(-512);
|
|
28
30
|
candidate.executions = mergeWith(
|
|
29
31
|
candidate.executions,
|
|
30
32
|
snapshot.executions,
|
|
31
33
|
(item) => `${item.computerId}\0${item.provider}\0${item.sessionId}`,
|
|
32
|
-
|
|
34
|
+
(current, incoming) => preferScopedExecution(candidate, current, incoming),
|
|
33
35
|
);
|
|
34
36
|
candidate.claims = mergeBy(candidate.claims, snapshot.claims, (item) => item.claimId);
|
|
35
37
|
candidate.dependencies = mergeBy(
|
|
@@ -37,7 +39,6 @@ export function mergeSnapshotState(state: StoreState, input: SnapshotValue): voi
|
|
|
37
39
|
snapshot.dependencies,
|
|
38
40
|
(item) => `${item.taskId}\0${item.dependsOnTaskId}`,
|
|
39
41
|
);
|
|
40
|
-
candidate.events = mergeBy(candidate.events, snapshot.events, (item) => item.eventId).slice(-512);
|
|
41
42
|
for (const incoming of snapshot.knowledge ?? []) {
|
|
42
43
|
const known = candidate.knowledge.find((item) => item.projectId === incoming.projectId
|
|
43
44
|
&& item.recordId === incoming.recordId);
|
|
@@ -28,14 +28,14 @@ import { projectSharedSkills } from "../../capabilities/skills";
|
|
|
28
28
|
import { loadExecutionPolicy } from "../runtime/execution-policy";
|
|
29
29
|
import { loadEnvironment, redactEnvironment } from "../context/env";
|
|
30
30
|
import { loadRestrictions } from "../restrictions";
|
|
31
|
-
import {
|
|
31
|
+
import { MeshPreparedStore } from "./receipts/prepared-availability";
|
|
32
32
|
import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
|
|
33
33
|
import { restoreHandoffResult, validStoredReceipt } from './receipts';
|
|
34
34
|
import {
|
|
35
35
|
projectCapabilityRequests,
|
|
36
36
|
} from "../catalog/project/requests";
|
|
37
37
|
|
|
38
|
-
export class MeshStore extends
|
|
38
|
+
export class MeshStore extends MeshPreparedStore {
|
|
39
39
|
recordReceipt(input: ReceiptValue): void {
|
|
40
40
|
this.sync();
|
|
41
41
|
const receipt = HandoffReceipt.parse(input);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExecutionSessionLink } from '../../../../../../packages/protocol/schema';
|
|
2
|
+
import { isTerminalTaskState, preferExecution } from '../../admin/convergence';
|
|
3
|
+
import type { StoreState } from '../state';
|
|
4
|
+
|
|
5
|
+
/** Ownership has already converged before availability; an ended former owner never qualifies. */
|
|
6
|
+
export function preferScopedExecution(state: StoreState, current: ExecutionSessionLink, incoming: ExecutionSessionLink) {
|
|
7
|
+
const task = state.tasks.find(row => row.taskId === incoming.taskId);
|
|
8
|
+
const preparedOwner = !!task && !isTerminalTaskState(task.state) && task.ownerSessionId === incoming.sessionId;
|
|
9
|
+
return preferExecution(current, incoming, preparedOwner);
|
|
10
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PreparedTransfer } from '../../../task-transfer';
|
|
2
|
+
import { isTerminalTaskState, nextTaskRevision } from '../../admin/convergence';
|
|
3
|
+
import { MeshStoreBase } from '../base';
|
|
4
|
+
import { validStoredReceipt } from './index';
|
|
5
|
+
import { capsuleHash } from '../../handoff';
|
|
6
|
+
import type { StoreState } from '../state';
|
|
7
|
+
|
|
8
|
+
/** Repairs only an unstarted, durably accepted local preparation. */
|
|
9
|
+
export class MeshPreparedStore extends MeshStoreBase {
|
|
10
|
+
reconcilePreparedTransfers(rows: PreparedTransfer[], verify: (row: PreparedTransfer) => boolean): number {
|
|
11
|
+
this.sync();
|
|
12
|
+
if (!rows.some(row => repairable(this.state, row))) return 0;
|
|
13
|
+
const result = this.transact(() => {
|
|
14
|
+
let repaired = 0;
|
|
15
|
+
for (const row of rows) {
|
|
16
|
+
const facts = repairable(this.state, row);
|
|
17
|
+
if (!facts || !verify(row)) continue;
|
|
18
|
+
const at = Math.max(this.now(), (facts.destination.endedAt ?? 0) + 1, facts.task.updatedAt);
|
|
19
|
+
facts.destination.endedAt = undefined;
|
|
20
|
+
facts.destination.updatedAt = at;
|
|
21
|
+
facts.task.ownerSessionId = row.sessionId;
|
|
22
|
+
facts.task.state = 'planned';
|
|
23
|
+
facts.task.updatedAt = at;
|
|
24
|
+
facts.task.revision = nextTaskRevision(facts.task);
|
|
25
|
+
repaired += 1;
|
|
26
|
+
}
|
|
27
|
+
return repaired;
|
|
28
|
+
});
|
|
29
|
+
return result.applied ? result.value : 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function repairable(state: StoreState, row: PreparedTransfer) {
|
|
34
|
+
if (row.phase !== 'prepared' || row.nativeSessionId) return undefined;
|
|
35
|
+
const task = state.tasks.find(item => item.taskId === row.capsule.taskId && item.projectId === row.projectId);
|
|
36
|
+
if (!task || isTerminalTaskState(task.state)
|
|
37
|
+
|| ![row.sourceSessionId, row.sessionId].includes(task.ownerSessionId ?? '')) return undefined;
|
|
38
|
+
const destination = state.executions.find(item => item.taskId === task.taskId && item.sessionId === row.sessionId
|
|
39
|
+
&& item.provider === row.capsule.targetProvider && item.computerId === row.capsule.targetComputer
|
|
40
|
+
&& item.workspace === row.workspace && item.startedAt === row.acceptedAt);
|
|
41
|
+
if (!destination || destination.endedAt == null && task.ownerSessionId === row.sessionId) return undefined;
|
|
42
|
+
const request = state.events.find(item => item.eventId === row.eventId && item.eventType === 'HANDOFF_REQUEST'
|
|
43
|
+
&& item.taskId === task.taskId && item.projectId === row.projectId && item.sourceSessionId === row.sourceSessionId);
|
|
44
|
+
if (!request || state.events.some(item => item.taskId === task.taskId && item.eventType === 'HANDOFF_REQUEST'
|
|
45
|
+
&& item.eventId !== row.eventId && item.createdAt >= request.createdAt)) return undefined;
|
|
46
|
+
const accepted = state.events.find(item => item.eventType === 'HANDOFF_ACCEPTED' && item.taskId === task.taskId
|
|
47
|
+
&& item.projectId === row.projectId && item.sourceSessionId === row.sessionId
|
|
48
|
+
&& item.payload.receipt?.prepared === true && item.payload.receipt.targetSessionId === row.sessionId
|
|
49
|
+
&& item.payload.receipt.sourceSessionId === row.sourceSessionId && item.payload.receipt.acceptedAt >= row.acceptedAt
|
|
50
|
+
&& item.payload.receipt.capsuleHash === capsuleHash(row.capsule));
|
|
51
|
+
const source = state.executions.find(item => item.taskId === task.taskId && item.sessionId === row.sourceSessionId);
|
|
52
|
+
if (!accepted || !validStoredReceipt(state, accepted) || !source?.endedAt
|
|
53
|
+
|| source.continuedAt != null && source.continuedAt >= row.acceptedAt) return undefined;
|
|
54
|
+
return { task, destination };
|
|
55
|
+
}
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
MeshTask as TaskValue,
|
|
20
20
|
} from "../../../../../packages/protocol/schema";
|
|
21
21
|
import { preferExecution, preferTask } from "../admin/convergence";
|
|
22
|
+
import { preferScopedExecution } from './receipts/execution-merge';
|
|
22
23
|
import { integrationPeerKey } from "../handoff/other-side";
|
|
23
24
|
import {
|
|
24
25
|
MAX_RELEASED_CLAIMS, MAX_STORE_MIGRATIONS, MAX_STORE_PEERS, type StoreState,
|
|
@@ -115,7 +116,7 @@ export function applyStoreDelta(disk: StoreState, delta: StoreDelta): StoreState
|
|
|
115
116
|
const key = rowKey(name, item);
|
|
116
117
|
const theirs = rows.get(key);
|
|
117
118
|
const untouched = theirs == null || JSON.stringify(theirs) === before;
|
|
118
|
-
rows.set(key, untouched ? item : settle(name, theirs, item));
|
|
119
|
+
rows.set(key, untouched ? item : settle(name, theirs, item, merged as StoreState));
|
|
119
120
|
}
|
|
120
121
|
let items = [...rows.values()];
|
|
121
122
|
const bound = BOUNDS[name];
|
|
@@ -125,9 +126,9 @@ export function applyStoreDelta(disk: StoreState, delta: StoreDelta): StoreState
|
|
|
125
126
|
return merged as StoreState;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
function settle(name: Collection, theirs: unknown, ours: unknown): unknown {
|
|
129
|
+
function settle(name: Collection, theirs: unknown, ours: unknown, state: StoreState): unknown {
|
|
129
130
|
if (name === "tasks") return preferTask(theirs as TaskValue, ours as TaskValue);
|
|
130
|
-
if (name === "executions") return
|
|
131
|
+
if (name === "executions") return preferScopedExecution(state, theirs as ExecutionValue, ours as ExecutionValue);
|
|
131
132
|
if (name === "knowledge") return theirs;
|
|
132
133
|
return ours;
|
|
133
134
|
}
|
|
@@ -44,6 +44,7 @@ import { requestProjectGraphRefresh } from "../graph-refresh";
|
|
|
44
44
|
import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
|
|
45
45
|
import { acceptPhoneAccountLink } from "../../account-recovery/phone-link";
|
|
46
46
|
import { approvalsStatus } from '../../approvals/state';
|
|
47
|
+
import { handleDeliveryReceiptQuery } from '../message-delivery/status-query';
|
|
47
48
|
|
|
48
49
|
export async function handleMonitorMessage(
|
|
49
50
|
client: RelayClient,
|
|
@@ -70,6 +71,8 @@ export async function handleMonitorMessage(
|
|
|
70
71
|
}
|
|
71
72
|
if (payload.type === "user.message") {
|
|
72
73
|
return handleInboundUserMessage(client, payload, publish, input.peerPublicKey);
|
|
74
|
+
} else if (payload.type === 'delivery.receipt.query') {
|
|
75
|
+
return handleDeliveryReceiptQuery(client, payload, input.peerPublicKey);
|
|
73
76
|
} else if (payload.type === "user.attachment") {
|
|
74
77
|
// Ahead of its message, so the message itself travels light.
|
|
75
78
|
return storeAttachment(payload, client.room);
|
|
@@ -12,6 +12,7 @@ export async function sendDeliveryReceipt(
|
|
|
12
12
|
error?: string,
|
|
13
13
|
sessionId?: string,
|
|
14
14
|
peerPublicKey?: string,
|
|
15
|
+
agentActivityAt?: number,
|
|
15
16
|
): Promise<void> {
|
|
16
17
|
const payload = {
|
|
17
18
|
type: "delivery.receipt" as const,
|
|
@@ -20,9 +21,12 @@ export async function sendDeliveryReceipt(
|
|
|
20
21
|
status,
|
|
21
22
|
error,
|
|
22
23
|
receivedAt: Date.now(),
|
|
24
|
+
...(agentActivityAt ? { agentActivityAt } : {}),
|
|
23
25
|
};
|
|
24
26
|
const options = { ttlMs: 24 * 60 * 60_000, peerPublicKey };
|
|
25
|
-
|
|
27
|
+
// A controller receipt is control-plane state. It remains device-encrypted,
|
|
28
|
+
// and must not depend on a separately retained task-key grant arriving first.
|
|
29
|
+
await (sessionId && !peerPublicKey
|
|
26
30
|
? sendSessionPayload(client, payload, sessionId, "phone", options)
|
|
27
31
|
: client.send(payload, "phone", options)).catch(() => {});
|
|
28
32
|
}
|
|
@@ -21,6 +21,7 @@ import { publishedSession } from "../../session-catalog";
|
|
|
21
21
|
import { localTaskOrigins, type TaskOriginRegistry, type TaskOriginScope } from '../../task-origins';
|
|
22
22
|
import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
|
|
23
23
|
import { resolveMessageAttachments } from "./receipts";
|
|
24
|
+
import { reportMessageAgentActivity } from '../message-delivery/activity';
|
|
24
25
|
import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
|
|
25
26
|
|
|
26
27
|
async function startNewAgentTask(input: {
|
|
@@ -137,7 +138,7 @@ export async function handleUserMessage(
|
|
|
137
138
|
const resolved = resolveMessageAttachments(message, (attachmentId) => takeAttachment(attachmentId, client.room));
|
|
138
139
|
if (!resolved.ok) {
|
|
139
140
|
if (message.messageId) {
|
|
140
|
-
await sendDeliveryReceipt(client, message.messageId, "rejected", ATTACHMENT_MISSING_ERROR, message.sessionId);
|
|
141
|
+
await sendDeliveryReceipt(client, message.messageId, "rejected", ATTACHMENT_MISSING_ERROR, message.sessionId, peerPublicKey);
|
|
141
142
|
}
|
|
142
143
|
return "rejected";
|
|
143
144
|
}
|
|
@@ -172,6 +173,7 @@ export async function handleUserMessage(
|
|
|
172
173
|
const startedAt = Date.now();
|
|
173
174
|
const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
|
|
174
175
|
messageId: message.messageId,
|
|
176
|
+
onAgentActivity: () => reportMessageAgentActivity(client, message, peerPublicKey),
|
|
175
177
|
preferredMcp: message.preferredMcp,
|
|
176
178
|
skill: message.skill,
|
|
177
179
|
model: message.model,
|
|
@@ -16,3 +16,13 @@ This module uses the repository GrantTap Commercial Source License.
|
|
|
16
16
|
Replies and receipts target the requesting controller. A provider refusal after
|
|
17
17
|
the processing acknowledgement emits a rejected receipt and retains that final
|
|
18
18
|
outcome across ciphertext retries; it never silently turns back into accepted.
|
|
19
|
+
|
|
20
|
+
`delivery.receipt.query` reads the original durable result without sending a
|
|
21
|
+
message again. Only the original authenticated controller, room, and session
|
|
22
|
+
may read it. Older unscoped records are not exposed through this query.
|
|
23
|
+
Receipts to a known controller use its device encryption directly, so a missed
|
|
24
|
+
task-key grant cannot hide a provider rejection or processing acknowledgement.
|
|
25
|
+
|
|
26
|
+
A correlated `agentActivityAt` receipt is emitted only after provider stream output
|
|
27
|
+
confirms activity. It is retained under the original controller, room and session
|
|
28
|
+
scope, survives receipt refresh, and does not claim a final response.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RelayClient } from '../../../../../packages/core/relay-client';
|
|
2
|
+
import type { UserMessage } from '../../../../../packages/protocol/schema';
|
|
3
|
+
import { acknowledgeAgentActivity } from '../../delivery';
|
|
4
|
+
import { sendDeliveryReceipt } from '../handlers/receipts';
|
|
5
|
+
|
|
6
|
+
/** The provider stream confirms activity for one authenticated original operation. */
|
|
7
|
+
export function reportMessageAgentActivity(client: RelayClient, message: UserMessage, peerPublicKey?: string): void {
|
|
8
|
+
if (!message.messageId || !message.sessionId || !peerPublicKey) return;
|
|
9
|
+
const at = acknowledgeAgentActivity(message.messageId, Date.now(), {
|
|
10
|
+
room: client.room, sessionId: message.sessionId, peerPublicKey,
|
|
11
|
+
});
|
|
12
|
+
if (at) void sendDeliveryReceipt(client, message.messageId, 'accepted', undefined,
|
|
13
|
+
message.sessionId, peerPublicKey, at);
|
|
14
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Payload } from "../../../../../packages/protocol/schema";
|
|
2
2
|
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
3
3
|
import { abandonDelivery, beginDelivery, completeDelivery,
|
|
4
|
-
acknowledgeProcessingDelivery, processingDeliveryAcknowledged, completedDeliveryFailure } from "../../delivery";
|
|
4
|
+
acknowledgeProcessingDelivery, processingDeliveryAcknowledged, completedDeliveryFailure, deliveryReceiptStatus } from "../../delivery";
|
|
5
5
|
import { sendDeliveryReceipt } from "../handlers/receipts";
|
|
6
6
|
import { handleUserMessage } from "../handlers/user-message";
|
|
7
7
|
|
|
@@ -14,12 +14,15 @@ export async function handleInboundUserMessage(
|
|
|
14
14
|
if (payload.requestId) return false;
|
|
15
15
|
let deliveryStarted = false;
|
|
16
16
|
if (payload.messageId) {
|
|
17
|
-
const state = beginDelivery(payload.messageId)
|
|
17
|
+
const state = beginDelivery(payload.messageId, Date.now(), payload.sessionId && peerPublicKey
|
|
18
|
+
? { room: client.room, sessionId: payload.sessionId, peerPublicKey } : undefined);
|
|
18
19
|
if (state !== "started") {
|
|
19
20
|
if (state === "completed" || processingDeliveryAcknowledged(payload.messageId)) {
|
|
20
21
|
const failure = completedDeliveryFailure(payload.messageId);
|
|
21
22
|
await sendDeliveryReceipt(client, payload.messageId, failure ? 'rejected' : 'accepted',
|
|
22
|
-
failure, payload.sessionId, peerPublicKey
|
|
23
|
+
failure, payload.sessionId, peerPublicKey, peerPublicKey && payload.sessionId
|
|
24
|
+
? deliveryReceiptStatus(payload.messageId, { room: client.room, peerPublicKey,
|
|
25
|
+
sessionId: payload.sessionId })?.agentActivityAt : undefined);
|
|
23
26
|
}
|
|
24
27
|
return true;
|
|
25
28
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RelayClient } from '../../../../../packages/core/relay-client';
|
|
2
|
+
import type { DeliveryReceiptQuery } from '../../../../../packages/protocol/schema';
|
|
3
|
+
import { deliveryReceiptStatus } from '../../delivery';
|
|
4
|
+
import { sendDeliveryReceipt } from '../handlers/receipts';
|
|
5
|
+
|
|
6
|
+
export async function handleDeliveryReceiptQuery(client: RelayClient, query: DeliveryReceiptQuery,
|
|
7
|
+
peerPublicKey?: string): Promise<boolean> {
|
|
8
|
+
if (!peerPublicKey) return true;
|
|
9
|
+
const receipt = deliveryReceiptStatus(query.messageId, { room: client.room, peerPublicKey, sessionId: query.sessionId });
|
|
10
|
+
if (receipt) await sendDeliveryReceipt(client, query.messageId, receipt.status, receipt.error, query.sessionId, peerPublicKey, receipt.agentActivityAt);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Native Grok activity
|
|
2
|
+
|
|
3
|
+
`grokNativeActivity` reads at most 64 KiB of the native structured event log.
|
|
4
|
+
It confirms the active registry PID and OS process start time before treating a
|
|
5
|
+
turn as running. Summary writes alone cannot prove that Grok is idle during a
|
|
6
|
+
long turn. Permission phases remain waiting; completed turns are idle.
|
|
7
|
+
|
|
8
|
+
The session catalog consumes these observations, and the resume adapter checks
|
|
9
|
+
them again before launching a headless process. A queued phone message waits
|
|
10
|
+
for the current native turn instead of loading the same running session twice.
|
|
11
|
+
No prompt, reasoning, or native event contents are published by this reader.
|
|
12
|
+
|
|
13
|
+
Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
type LiveActivity = { state: 'working' | 'waiting' | 'idle'; updatedAt: number };
|
|
7
|
+
|
|
8
|
+
export function grokResumeActivity(session: { sessionId: string; cwd?: string }): LiveActivity | undefined {
|
|
9
|
+
if (!session.cwd || !/^[a-zA-Z0-9_-]{1,256}$/.test(session.sessionId)) return undefined;
|
|
10
|
+
const root = process.env.GRANTTAP_GROK_SESSIONS_DIR
|
|
11
|
+
?? join(process.env.GROK_HOME ?? join(homedir(), '.grok'), 'sessions');
|
|
12
|
+
return grokNativeActivity(session.sessionId, join(root, encodeURIComponent(session.cwd), session.sessionId));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Native events, unlike summary.json, advance during a long running turn. */
|
|
16
|
+
export function grokNativeActivity(sessionId: string, directory: string, now = Date.now()): LiveActivity | undefined {
|
|
17
|
+
const root = process.env.GRANTTAP_GROK_DIR ?? process.env.GROK_HOME ?? join(homedir(), '.grok');
|
|
18
|
+
const owned = ownedSession(root, sessionId);
|
|
19
|
+
const lines = tail(join(directory, 'events.jsonl'));
|
|
20
|
+
for (const line of lines.reverse()) {
|
|
21
|
+
let row: Record<string, unknown>;
|
|
22
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
23
|
+
const updatedAt = typeof row.ts === 'string' ? Date.parse(row.ts) : NaN;
|
|
24
|
+
if (!Number.isFinite(updatedAt) || updatedAt > now + 5_000) continue;
|
|
25
|
+
if (row.type === 'turn_ended') return { state: 'idle', updatedAt };
|
|
26
|
+
if (row.type === 'turn_started') return owned ? { state: 'working', updatedAt } : undefined;
|
|
27
|
+
if (row.type !== 'phase_changed') continue;
|
|
28
|
+
if (row.phase === 'permission_prompt') return owned ? { state: 'waiting', updatedAt } : undefined;
|
|
29
|
+
if (['waiting_for_model', 'streaming_reasoning', 'streaming_text', 'tool_execution'].includes(String(row.phase))) {
|
|
30
|
+
return owned ? { state: 'working', updatedAt } : undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ownedSession(root: string, sessionId: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const path = join(root, 'active_sessions.json');
|
|
39
|
+
if (statSync(path).size > 512_000) return false;
|
|
40
|
+
const rows = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
if (!Array.isArray(rows) || rows.length > 256) return false;
|
|
42
|
+
const row = rows.find(item => item?.session_id === sessionId);
|
|
43
|
+
if (!row || !Number.isSafeInteger(row.pid) || row.pid <= 0 || !Number.isFinite(row.pid_start)) return false;
|
|
44
|
+
process.kill(row.pid, 0);
|
|
45
|
+
// Grok records microseconds. Match the OS start time so a reused PID cannot hold a Task.
|
|
46
|
+
const started = Date.parse(execFileSync('ps', ['-p', String(row.pid), '-o', 'lstart='], {
|
|
47
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 500,
|
|
48
|
+
env: { ...process.env, LC_ALL: 'C' }, maxBuffer: 1_024,
|
|
49
|
+
}).trim());
|
|
50
|
+
return Math.abs(started - row.pid_start / 1_000) < 1_000;
|
|
51
|
+
} catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tail(path: string): string[] {
|
|
55
|
+
let fd: number | undefined;
|
|
56
|
+
try {
|
|
57
|
+
const size = statSync(path).size, length = Math.min(size, 64 * 1_024);
|
|
58
|
+
fd = openSync(path, 'r'); const buffer = Buffer.alloc(length);
|
|
59
|
+
const read = readSync(fd, buffer, 0, length, size - length);
|
|
60
|
+
const lines = buffer.subarray(0, read).toString('utf8').split('\n');
|
|
61
|
+
return size > length ? lines.slice(1) : lines;
|
|
62
|
+
} catch { return []; }
|
|
63
|
+
finally { if (fd != null) closeSync(fd); }
|
|
64
|
+
}
|
|
@@ -65,9 +65,15 @@ export function deliverToSession(
|
|
|
65
65
|
if (/^transfer-[a-f0-9]{48}$/.test(session.sessionId)) {
|
|
66
66
|
return queued(session.sessionId, async () => (await localTaskTransfers().deliver(
|
|
67
67
|
session.sessionId, text, attachments, options, {
|
|
68
|
-
start: request =>
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
start: request => request.provider === "grok"
|
|
69
|
+
? createGrokSession(request.text, request.cwd, timeoutMs, request.attachments,
|
|
70
|
+
request.model, request.operationKey, request.effort, nativeId => {
|
|
71
|
+
request.onNativeSession(nativeId);
|
|
72
|
+
options.onAgentActivity?.(nativeId);
|
|
73
|
+
})
|
|
74
|
+
: ({ claude: createClaudeSession, codex: createCodexSession,
|
|
75
|
+
cursor: createCursorSession }[request.provider])(request.text, request.cwd,
|
|
76
|
+
timeoutMs, request.attachments, request.model, request.operationKey, request.effort),
|
|
71
77
|
resume: (native, body, images, settings) => withAttachments(images, body, prepared => runDelivery(native,
|
|
72
78
|
native.agent === "codex" ? prepared.prompt : prepared.claudePrompt, timeoutMs, prepared.images, settings)),
|
|
73
79
|
},
|
|
@@ -139,10 +145,11 @@ export function createGrokSession(
|
|
|
139
145
|
model?: string,
|
|
140
146
|
operationKey?: string,
|
|
141
147
|
effort?: string,
|
|
148
|
+
onNativeSession?: (sessionId: string) => void,
|
|
142
149
|
): Promise<ReplyResult> {
|
|
143
150
|
const workspace = resolveAgentWorkspace(cwd, "grok");
|
|
144
151
|
return queued("__new_grok_task__", () => withAttachments(attachments, text, (prepared) =>
|
|
145
|
-
runGrokNew(prepared.claudePrompt, workspace, timeoutMs, model, operationKey, effort),
|
|
152
|
+
runGrokNew(prepared.claudePrompt, workspace, timeoutMs, model, operationKey, effort, onNativeSession),
|
|
146
153
|
));
|
|
147
154
|
}
|
|
148
155
|
|
|
@@ -3,6 +3,8 @@ export type ReplyResult =
|
|
|
3
3
|
| { ok: false; error: string; delivery?: 'native' };
|
|
4
4
|
|
|
5
5
|
export type DeliveryOptions = {
|
|
6
|
+
/** Called only after provider output confirms this operation has started. */
|
|
7
|
+
onAgentActivity?: (nativeSessionId: string) => void;
|
|
6
8
|
/** Stable controller delivery ID, also used by the provider's native admission. */
|
|
7
9
|
messageId?: string;
|
|
8
10
|
/** Computer-generated identity before temporary attachment paths are inserted. */
|
|
@@ -74,6 +74,7 @@ export function runningProcessCount(key: string): number {
|
|
|
74
74
|
|
|
75
75
|
export type ProviderAdmission = {
|
|
76
76
|
confirmed: (stdout: string) => boolean;
|
|
77
|
+
onConfirmed?: () => void;
|
|
77
78
|
withinMs: number;
|
|
78
79
|
timeoutMessage: string;
|
|
79
80
|
};
|
|
@@ -107,6 +108,7 @@ export function runProcess(
|
|
|
107
108
|
let stdout = "";
|
|
108
109
|
let stderr = "";
|
|
109
110
|
let done = false;
|
|
111
|
+
let admissionReported = false;
|
|
110
112
|
const finish = (result: ReplyResult): void => {
|
|
111
113
|
if (done) return;
|
|
112
114
|
done = true;
|
|
@@ -125,7 +127,17 @@ export function runProcess(
|
|
|
125
127
|
signalOwnedProcess(child, "SIGKILL");
|
|
126
128
|
finish({ ok: false, error: admission.timeoutMessage });
|
|
127
129
|
}, admission.withinMs) : undefined;
|
|
128
|
-
child.stdout?.on("data", (chunk) => {
|
|
130
|
+
child.stdout?.on("data", (chunk) => {
|
|
131
|
+
stdout += chunk;
|
|
132
|
+
if (done || admissionReported || !admission?.confirmed(stdout)) return;
|
|
133
|
+
admissionReported = true;
|
|
134
|
+
clearTimeout(startupTimer);
|
|
135
|
+
try { admission.onConfirmed?.(); }
|
|
136
|
+
catch {
|
|
137
|
+
signalOwnedProcess(child, "SIGTERM");
|
|
138
|
+
finish({ ok: false, error: "Provider activity could not be recorded safely.", delivery: "native" });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
129
141
|
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
130
142
|
child.on("error", (error) => {
|
|
131
143
|
finish({ ok: false, error: `${command}: ${error.message}` });
|
|
@@ -7,6 +7,7 @@ import type { ReplyResult } from "../payload/types";
|
|
|
7
7
|
import type { DeliveryOptions } from "../payload/types";
|
|
8
8
|
import { resolveGrokBinary } from "../../providers/grok-bin";
|
|
9
9
|
import { runCursorConversation } from "../cursor-conversation";
|
|
10
|
+
import { grokResumeActivity } from '../../providers/grok-activity';
|
|
10
11
|
|
|
11
12
|
function grokBin(): string {
|
|
12
13
|
return resolveGrokBinary();
|
|
@@ -28,6 +29,7 @@ export async function runCursorResume(
|
|
|
28
29
|
|
|
29
30
|
export function runGrokNew(
|
|
30
31
|
text: string, cwd: string, timeoutMs: number, model?: string, operationKey?: string, effort?: string,
|
|
32
|
+
onNativeSession?: (sessionId: string) => void,
|
|
31
33
|
): Promise<ReplyResult> {
|
|
32
34
|
const sessionId = randomUUID();
|
|
33
35
|
const args = [
|
|
@@ -36,12 +38,16 @@ export function runGrokNew(
|
|
|
36
38
|
"-p", text, "--output-format", "streaming-json",
|
|
37
39
|
];
|
|
38
40
|
return runProcess(grokBin(), args, cwd, timeoutMs, (stdout) => parseGrok(stdout, sessionId),
|
|
39
|
-
undefined, operationKey, grokAdmission);
|
|
41
|
+
undefined, operationKey, { ...grokAdmission, onConfirmed: () => onNativeSession?.(sessionId) });
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export function runGrokResume(
|
|
43
45
|
session: SessionInfo, text: string, timeoutMs: number, options: DeliveryOptions = {},
|
|
44
46
|
): Promise<ReplyResult> {
|
|
47
|
+
const live = grokResumeActivity(session);
|
|
48
|
+
if (live?.state === 'working' || live?.state === 'waiting') return Promise.resolve({ ok: false,
|
|
49
|
+
error: live.state === 'working' ? 'Grok is still running a turn in this task. Wait for it to finish, then retry this message.'
|
|
50
|
+
: 'Grok is waiting for permission in its open task. Resolve that request, then retry this message.' });
|
|
45
51
|
const permission = options.permissionMode === "manual" ? "default" : options.permissionMode;
|
|
46
52
|
const args = [
|
|
47
53
|
"--no-auto-update", ...(session.cwd ? ["--cwd", session.cwd] : []),
|
|
@@ -53,6 +59,7 @@ export function runGrokResume(
|
|
|
53
59
|
return runProcess(
|
|
54
60
|
grokBin(), args, session.cwd, timeoutMs,
|
|
55
61
|
(stdout) => parseGrok(stdout, session.sessionId),
|
|
56
|
-
undefined, session.sessionId, grokAdmission,
|
|
62
|
+
undefined, session.sessionId, { ...grokAdmission,
|
|
63
|
+
onConfirmed: () => options.onAgentActivity?.(session.sessionId) },
|
|
57
64
|
);
|
|
58
65
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type Scan,
|
|
19
19
|
} from "../support/common";
|
|
20
20
|
import type { CapabilityObservation } from "../telemetry";
|
|
21
|
+
import { grokNativeActivity } from '../../providers/grok-activity';
|
|
21
22
|
|
|
22
23
|
type Summary = {
|
|
23
24
|
info?: { id?: unknown; cwd?: unknown };
|
|
@@ -170,10 +171,11 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
|
|
|
170
171
|
const title = [summary.generated_title, summary.session_summary, parsed.firstUser]
|
|
171
172
|
.find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
172
173
|
const cwd = typeof summary.info?.cwd === "string" ? summary.info.cwd : undefined;
|
|
174
|
+
const live = grokNativeActivity(sessionId, directory);
|
|
173
175
|
sessions.push({
|
|
174
176
|
sessionId, agent: "grok", title: title?.trim().slice(0, 120), cwd,
|
|
175
177
|
model: typeof summary.current_model_id === "string" ? summary.current_model_id : undefined,
|
|
176
|
-
state: stateFor(updatedAt), startedAt, lastActivityAt: updatedAt,
|
|
178
|
+
state: live?.state ?? stateFor(updatedAt), startedAt, lastActivityAt: Math.max(updatedAt, live?.updatedAt ?? 0),
|
|
177
179
|
...(parsed.lastMessageAt ? { lastMessageAt: parsed.lastMessageAt } : {}),
|
|
178
180
|
tokensSession: parsed.tokens, tokensLastTurn: parsed.lastTurn,
|
|
179
181
|
contextTokensUsed: parsed.tokens,
|
|
@@ -10,7 +10,18 @@ oversized context refuses preparation instead of silently dropping history.
|
|
|
10
10
|
A launch is recorded before invoking the provider. An interrupted first launch
|
|
11
11
|
requires inspection and cannot automatically create a second native execution.
|
|
12
12
|
|
|
13
|
+
Native catalog sweeps never close a prepared alias. The catalog can repair an
|
|
14
|
+
older false closure from the retained accepted receipt and checksummed, unstarted
|
|
15
|
+
preparation. It does not supersede a later transfer, reopen completed work, or
|
|
16
|
+
invent consent to resume the source. Existing Task ownership, the accepted
|
|
17
|
+
receipt, and a newer live observation keep availability through delayed snapshot
|
|
18
|
+
merges without changing the persisted execution format.
|
|
19
|
+
|
|
13
20
|
Tests: `tests/prepared.test.ts` covers preparation, restart, model/effort forwarding,
|
|
14
21
|
context persistence, stable catalog identity and ambiguous first submissions.
|
|
15
22
|
|
|
16
23
|
Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
|
|
24
|
+
|
|
25
|
+
The first Grok turn binds its confirmed native session before the final answer,
|
|
26
|
+
so the transferred Task can publish live native history while the turn runs.
|
|
27
|
+
The durable launch remains in progress and cannot start another execution.
|
|
@@ -2,11 +2,12 @@ import type { CodingAgent, SessionInfo, UserAttachment } from '../../../../packa
|
|
|
2
2
|
import type { DeliveryOptions, ReplyResult } from '../reply';
|
|
3
3
|
import { TransferPersistence } from './persistence';
|
|
4
4
|
import type { PreparedTransfer } from './records';
|
|
5
|
+
import type { MeshStore } from '../mesh/store';
|
|
5
6
|
export type { PreparedTransfer, TransferPreparation } from './records';
|
|
6
7
|
|
|
7
8
|
type TransferProvider = {
|
|
8
9
|
start: (request: { provider: CodingAgent; text: string; cwd: string; attachments: UserAttachment[];
|
|
9
|
-
model?: string; effort?: string; operationKey: string }) => Promise<ReplyResult>;
|
|
10
|
+
model?: string; effort?: string; operationKey: string; onNativeSession: (sessionId: string) => void }) => Promise<ReplyResult>;
|
|
10
11
|
resume: (session: SessionInfo, text: string, attachments: UserAttachment[], options: DeliveryOptions) => Promise<ReplyResult>;
|
|
11
12
|
};
|
|
12
13
|
const interrupted = (): ReplyResult => ({ ok: false,
|
|
@@ -14,6 +15,12 @@ const interrupted = (): ReplyResult => ({ ok: false,
|
|
|
14
15
|
|
|
15
16
|
/** Prepared executions are visible immediately and start only with a person's first message. */
|
|
16
17
|
export class PreparedTaskTransfers extends TransferPersistence {
|
|
18
|
+
reconcileCatalogOwnership(store: MeshStore, computerId: string): number {
|
|
19
|
+
return store.reconcilePreparedTransfers(this.records().filter(row => row.capsule.targetComputer === computerId), row => {
|
|
20
|
+
try { this.readContext(row); return true; } catch { return false; }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
catalog(native: SessionInfo[]): SessionInfo[] {
|
|
18
25
|
const rows = this.records().map(row => {
|
|
19
26
|
if (row.phase !== 'starting' || ownerAlive(row.ownerPid)) return row;
|
|
@@ -61,7 +68,17 @@ export class PreparedTaskTransfers extends TransferPersistence {
|
|
|
61
68
|
ownsLaunch = true;
|
|
62
69
|
const result = await provider.start({ provider: row.capsule.targetProvider as CodingAgent,
|
|
63
70
|
text: this.prompt(row, context, text), cwd: row.workspace, attachments,
|
|
64
|
-
model: options.model ?? row.capsule.targetModel, effort: options.effort, operationKey: id
|
|
71
|
+
model: options.model ?? row.capsule.targetModel, effort: options.effort, operationKey: id,
|
|
72
|
+
onNativeSession: nativeId => {
|
|
73
|
+
if (!/^[a-zA-Z0-9_-]{1,256}$/.test(nativeId)) throw new Error('Invalid native session identity');
|
|
74
|
+
this.update(id, current => {
|
|
75
|
+
if (current.phase !== 'starting' || current.ownerPid !== process.pid
|
|
76
|
+
|| current.nativeSessionId && current.nativeSessionId !== nativeId) {
|
|
77
|
+
throw new Error('The transfer no longer owns this provider launch');
|
|
78
|
+
}
|
|
79
|
+
return { ...current, nativeSessionId: nativeId, updatedAt: Date.now() };
|
|
80
|
+
});
|
|
81
|
+
} });
|
|
65
82
|
if (!result.ok || !result.sessionId) {
|
|
66
83
|
this.update(id, current => ({ ...current, phase: 'uncertain', updatedAt: Date.now() }));
|
|
67
84
|
return result.ok ? interrupted() : result;
|
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.98
|
|
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.98\";\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 22.13.0+ 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.98";
|
|
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
|
@@ -71,9 +71,9 @@ export class OutboundFrameQueue {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
export const priorityControlPayload = (type: Payload["type"]) => type === "hello" || type.startsWith("approval.")
|
|
75
75
|
|| type.startsWith("config.") || type.startsWith("pairing.") || type === "session.key.grant"
|
|
76
|
-
|| type === "sessions.status" || type === "machine.heartbeat" || type === "approvals.status";
|
|
76
|
+
|| type === "delivery.receipt" || type === "sessions.status" || type === "machine.heartbeat" || type === "approvals.status";
|
|
77
77
|
|
|
78
78
|
export async function sendEncryptedPayload(queue: OutboundFrameQueue, config: PeerConfig,
|
|
79
79
|
allowed: (key: string) => boolean,
|
|
@@ -83,6 +83,6 @@ export async function sendEncryptedPayload(queue: OutboundFrameQueue, config: Pe
|
|
|
83
83
|
if (!peers.length) throw new Error("no authorized pairing peers");
|
|
84
84
|
for (const peerPublicKey of peers) {
|
|
85
85
|
await queue.send(sealEnvelope({ ...config, peerPublicKey }, payload, to,
|
|
86
|
-
peers.length === 1 ? options : { ...options, deliveryId: undefined }),
|
|
86
|
+
peers.length === 1 ? options : { ...options, deliveryId: undefined }), priorityControlPayload(payload.type) || options.wake === true);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -2,7 +2,7 @@ import { retainedPayloadOptions } from "./retained-state";
|
|
|
2
2
|
/** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
|
|
3
3
|
import WebSocket from "ws";
|
|
4
4
|
import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, RelayConnectionNotifications, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
5
|
-
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
5
|
+
import { OutboundFrameQueue, sendEncryptedPayload, priorityControlPayload } from "./outbound-scheduler";
|
|
6
6
|
import { openRelayMessage, parseRelayControl, waitForRelayPayload } from "./relay-inbound";
|
|
7
7
|
import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
|
|
8
8
|
import { Payload, type Role } from "../protocol/schema";
|
|
@@ -213,7 +213,8 @@ export class RelayClient {
|
|
|
213
213
|
if (!peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys).includes(peerPublicKey)
|
|
214
214
|
|| !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
|
|
215
215
|
if (!this.frames) throw new Error("relay not connected");
|
|
216
|
-
await this.frames.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), { ...retainedPayloadOptions(payload), ...options, retainedState: this.retainedState, recipientRouting: this.recipientRouting }),
|
|
216
|
+
await this.frames.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), { ...retainedPayloadOptions(payload), ...options, retainedState: this.retainedState, recipientRouting: this.recipientRouting }),
|
|
217
|
+
priorityControlPayload(payload.type) || options.wake === true);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
setSessionKey(sessionId: string, key: string): void {
|
|
@@ -21,3 +21,7 @@ Consumers can immediately replace stale snapshots; notification failures cannot
|
|
|
21
21
|
interrupt the transport, and unsubscribing prevents later callbacks.
|
|
22
22
|
|
|
23
23
|
Licensed under the GrantTap Commercial Source License in the repository root.
|
|
24
|
+
|
|
25
|
+
Controller-addressed history uses the snapshot queue. Delivery receipts and
|
|
26
|
+
heartbeats use the control queue, so a history download cannot put them behind
|
|
27
|
+
its pending frames. An in-flight WebSocket frame is allowed to finish.
|
|
@@ -107,9 +107,17 @@ export const DeliveryReceipt = z.object({
|
|
|
107
107
|
status: z.enum(["accepted", "rejected"]),
|
|
108
108
|
error: z.string().max(500).optional(),
|
|
109
109
|
receivedAt: z.number(),
|
|
110
|
+
agentActivityAt: z.number().finite().positive().optional(),
|
|
110
111
|
});
|
|
111
112
|
export type DeliveryReceipt = z.infer<typeof DeliveryReceipt>;
|
|
112
113
|
|
|
114
|
+
/** Reads the original operation; it cannot submit or resume a provider turn. */
|
|
115
|
+
export const DeliveryReceiptQuery = z.object({
|
|
116
|
+
type: z.literal('delivery.receipt.query'), messageId: z.string().min(1).max(180),
|
|
117
|
+
sessionId: z.string().min(1).max(256), createdAt: z.number().finite(),
|
|
118
|
+
}).strict();
|
|
119
|
+
export type DeliveryReceiptQuery = z.infer<typeof DeliveryReceiptQuery>;
|
|
120
|
+
|
|
113
121
|
export const AgentEvent = z.object({
|
|
114
122
|
type: z.literal("agent.event"),
|
|
115
123
|
text: z.string(),
|
|
@@ -21,6 +21,7 @@ import { CapabilityUsageStatus } from "./messages/capabilities";
|
|
|
21
21
|
import {
|
|
22
22
|
AgentEvent,
|
|
23
23
|
DeliveryReceipt,
|
|
24
|
+
DeliveryReceiptQuery,
|
|
24
25
|
MAX_ATTACHMENT_BASE64_CHARS,
|
|
25
26
|
SessionEventsRequest,
|
|
26
27
|
SessionsRefresh,
|
|
@@ -130,6 +131,7 @@ export const Payload = z.union([
|
|
|
130
131
|
HostGrant,
|
|
131
132
|
UserAttachmentUpload,
|
|
132
133
|
DeliveryReceipt,
|
|
134
|
+
DeliveryReceiptQuery,
|
|
133
135
|
AgentEvent,
|
|
134
136
|
SessionSubscription,
|
|
135
137
|
SessionEventsRequest,
|