granttap-mcp 0.8.69 → 0.8.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/apps/bridge/src/mesh/README.md +10 -6
- package/apps/bridge/src/mesh/admin/capsule.ts +20 -4
- package/apps/bridge/src/mesh/runtime/graph/binding-sync.ts +36 -13
- package/apps/bridge/src/mesh/runtime/index.ts +3 -7
- package/apps/bridge/src/mesh/runtime/prepared-transfer/source-checkout.ts +24 -0
- package/apps/bridge/src/mesh/tasks/readiness.ts +16 -18
- package/apps/bridge/src/monitor/README.md +7 -3
- package/apps/bridge/src/monitor/handlers/receipts.ts +0 -2
- package/apps/bridge/src/monitor/handlers/task-create.ts +12 -10
- package/apps/bridge/src/monitor/handlers/user-message.ts +36 -32
- package/apps/bridge/src/monitor/message-delivery/README.md +3 -0
- package/apps/bridge/src/monitor/message-delivery/acceptance.ts +33 -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/mesh/events.ts +3 -2
package/README.md
CHANGED
|
@@ -207,10 +207,10 @@ offered for it. Unsupported remote-start paths fail closed instead of claiming
|
|
|
207
207
|
parity.
|
|
208
208
|
|
|
209
209
|
A capsule carries facts, not files. A handoff from a checkout with uncommitted
|
|
210
|
-
changes
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
210
|
+
changes goes ahead with a warning — "This task has uncommitted changes. They stay
|
|
211
|
+
on this computer; the next agent starts from the last commit." — and the capsule
|
|
212
|
+
names the changes that stayed behind, so nothing is narrowed silently. Asked to
|
|
213
|
+
checkpoint from the phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
214
214
|
from a temporary index, so HEAD, the current branch, and the working tree stay
|
|
215
215
|
exactly as the agent left them, and the capsule carries that commit. Nothing is
|
|
216
216
|
pushed unless the phone asks for it per handoff: then the source publishes the
|
|
@@ -26,12 +26,16 @@ tool so the public MCP tool allowlist remains exactly `connect`, `notify`,
|
|
|
26
26
|
CLI-only.
|
|
27
27
|
|
|
28
28
|
`readiness.ts` decides whether a handoff may leave at all. A capsule transfers
|
|
29
|
-
committed facts, so uncommitted work
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
committed facts, so uncommitted work stays on the source computer: that is the
|
|
30
|
+
person's call, made with a warning rather than refused, and the capsule tells
|
|
31
|
+
the next agent which changes stayed where. The destination refuses explicitly
|
|
32
|
+
when it lacks the named commit or when the capsule's resource claims overlap
|
|
33
|
+
another execution. "Uncommitted" is read from `git status` including untracked
|
|
34
|
+
files, because an untracked file is work no commit would carry. A probe that
|
|
35
|
+
cannot answer publishes `unknown`, which is warned about the same way, never
|
|
36
|
+
taken for `clean`. A prepared transfer to another computer is confirmed only
|
|
37
|
+
while the source checkout is still on the same commit with the same uncommitted
|
|
38
|
+
changes it had when the person agreed to the move.
|
|
35
39
|
`remote.ts` is the one place a remote is touched: a push of the branch the
|
|
36
40
|
capsule names when the phone asked for it, never forced, and a fetch of that
|
|
37
41
|
branch on a destination that lacks the commit. A handoff whose target is this
|
|
@@ -42,11 +42,22 @@ function changedFiles(cwd: string): string[] {
|
|
|
42
42
|
return [...new Set(files)].slice(0, 64);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
/** The identity of the uncommitted changes, so a later reading can tell they are the same. */
|
|
46
|
+
export function dirtyDiffHash(cwd: string): string | undefined {
|
|
46
47
|
const diff = git(cwd, ["diff", "--binary", "HEAD"]);
|
|
47
48
|
return diff ? createHash("sha256").update(diff).digest("hex") : undefined;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Uncommitted work that does not travel with the capsule. The move is allowed;
|
|
53
|
+
* the next agent is told what it does not have and where it stayed.
|
|
54
|
+
*/
|
|
55
|
+
function leftBehindWork(files: string[], computerId: string): string {
|
|
56
|
+
const named = files.slice(0, 8).join(", ") + (files.length > 8 ? ` and ${files.length - 8} more` : "");
|
|
57
|
+
return `Uncommitted changes to ${named || "this checkout"} stayed on ${computerId}; they are not in this commit.`
|
|
58
|
+
.slice(0, 1_000);
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export function buildTaskCapsule(
|
|
51
62
|
store: MeshStore,
|
|
52
63
|
session: SessionInfo,
|
|
@@ -61,6 +72,8 @@ export function buildTaskCapsule(
|
|
|
61
72
|
if (!cwd || !task || !project || session.taskId !== request.taskId) return undefined;
|
|
62
73
|
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
63
74
|
if (!head) return undefined;
|
|
75
|
+
const tree = workingTreeState(cwd);
|
|
76
|
+
const files = changedFiles(cwd);
|
|
64
77
|
const capsule = {
|
|
65
78
|
taskId: request.taskId,
|
|
66
79
|
goal: task.goal,
|
|
@@ -77,13 +90,16 @@ export function buildTaskCapsule(
|
|
|
77
90
|
branch: session.branch ?? git(cwd, ["branch", "--show-current"]),
|
|
78
91
|
latestCommit: head,
|
|
79
92
|
dirtyDiffHash: dirtyDiffHash(cwd),
|
|
80
|
-
workingTree:
|
|
81
|
-
filesChanged:
|
|
93
|
+
workingTree: tree,
|
|
94
|
+
filesChanged: files,
|
|
82
95
|
dependencies: snapshot.dependencies
|
|
83
96
|
.filter((item) => item.taskId === request.taskId).map((item) => item.dependsOnTaskId).slice(0, 32),
|
|
84
97
|
resourceClaims: snapshot.claims
|
|
85
98
|
.filter((item) => item.taskId === request.taskId).map((item) => item.resource).slice(0, 64),
|
|
86
|
-
remainingWork: [
|
|
99
|
+
remainingWork: [
|
|
100
|
+
"Continue the task from the current committed state.",
|
|
101
|
+
...(tree === "dirty" ? [leftBehindWork(files, computerId)] : []),
|
|
102
|
+
],
|
|
87
103
|
importantDecisions: [],
|
|
88
104
|
createdAt: Date.now(),
|
|
89
105
|
};
|
|
@@ -5,7 +5,15 @@ import { inspectRepository } from "../../catalog";
|
|
|
5
5
|
import { computerId } from "../../identity/computer";
|
|
6
6
|
import { matchesLocalCheckout } from "./checkout-identity";
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Re-admit this computer's verified checkouts before an explicit Graph build.
|
|
10
|
+
*
|
|
11
|
+
* Only a repository whose Graph is genuinely unavailable is reported. A plain
|
|
12
|
+
* folder an agent ran in, a subfolder of a repository, a removed checkout and
|
|
13
|
+
* a former identity of a checkout another binding already describes are not
|
|
14
|
+
* repositories with a broken Graph; reporting them filled Mesh Health with
|
|
15
|
+
* errors nobody could act on.
|
|
16
|
+
*/
|
|
9
17
|
export async function refreshLocalGraphBindings(
|
|
10
18
|
snapshot: MeshSnapshot,
|
|
11
19
|
dependencies: {
|
|
@@ -18,19 +26,23 @@ export async function refreshLocalGraphBindings(
|
|
|
18
26
|
const candidates = (snapshot.bindings ?? []).filter((binding) =>
|
|
19
27
|
binding.endpointId === localEndpoint && binding.available && binding.localPathHint);
|
|
20
28
|
const failures: ProjectRepositoryGraph[] = [];
|
|
21
|
-
|
|
29
|
+
const checkouts = candidates.flatMap((binding) => {
|
|
30
|
+
const facts = readCheckout(dependencies.inspect, binding.localPathHint!);
|
|
31
|
+
return facts ? [{ binding, facts, real: realpathSync(facts.root) }] : [];
|
|
32
|
+
});
|
|
33
|
+
await Promise.all(checkouts.map(async ({ binding, facts, real }) => {
|
|
34
|
+
const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
|
|
35
|
+
if (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity) {
|
|
36
|
+
const described = checkouts.some((other) => other.real === real
|
|
37
|
+
&& (other.binding.repositoryId === facts.canonicalRepositoryId
|
|
38
|
+
|| matchesLocalCheckout(other.binding.repositoryId, facts.root)));
|
|
39
|
+
if (!described) failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
|
|
40
|
+
revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
|
|
41
|
+
analysisErrorCode: "REPOSITORY_IDENTITY_MISMATCH",
|
|
42
|
+
nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
22
45
|
try {
|
|
23
|
-
const path = binding.localPathHint!;
|
|
24
|
-
const facts = dependencies.inspect(path);
|
|
25
|
-
const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
|
|
26
|
-
if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)
|
|
27
|
-
|| (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity)) {
|
|
28
|
-
failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
|
|
29
|
-
revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
|
|
30
|
-
analysisErrorCode: facts.worktree ? "REPOSITORY_IDENTITY_MISMATCH" : "REPOSITORY_IDENTITY_UNVERIFIED",
|
|
31
|
-
nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
46
|
const admitted = await dependencies.sync(snapshot.project, {
|
|
35
47
|
summary: binding,
|
|
36
48
|
localRoot: facts.root,
|
|
@@ -45,6 +57,17 @@ export async function refreshLocalGraphBindings(
|
|
|
45
57
|
return failures;
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
/** A Git checkout rooted exactly at the bound path, or nothing a Graph can be built from. */
|
|
61
|
+
function readCheckout(inspect: typeof inspectRepository, path: string) {
|
|
62
|
+
try {
|
|
63
|
+
const facts = inspect(path);
|
|
64
|
+
if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)) return undefined;
|
|
65
|
+
return facts;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
function admissionFailure(snapshot: MeshSnapshot, binding: NonNullable<MeshSnapshot["bindings"]>[number]): ProjectRepositoryGraph {
|
|
49
72
|
return { projectId: snapshot.projectId, repositoryId: binding.repositoryId,
|
|
50
73
|
revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../../reply";
|
|
22
22
|
import { isProviderEnabled } from "../../config/runtime";
|
|
23
23
|
import { sendMeshPayload } from "../../host/session-keys";
|
|
24
|
-
import {
|
|
24
|
+
import { linkSessionsToProjects, workingTreeState } from "../catalog";
|
|
25
25
|
import { catalogFromEndpoint } from "../catalog/models";
|
|
26
26
|
import { saveProjectCapabilityRequest } from "../catalog/project/requests";
|
|
27
27
|
import { computerId } from "../identity/computer";
|
|
@@ -38,6 +38,7 @@ import { fetchRevision, pushBranch } from "../local-remote/remote";
|
|
|
38
38
|
import { projectSessionCapabilityInventory } from "./capabilities";
|
|
39
39
|
import { createMeshSessionCatalog } from "./catalog/session-catalog";
|
|
40
40
|
import { capturePreparedHandoff, preparedHandoffMetadata } from './prepared-transfer';
|
|
41
|
+
import { sourceCheckoutUnchanged } from './prepared-transfer/source-checkout';
|
|
41
42
|
|
|
42
43
|
export type { MeshRuntimeDependencies };
|
|
43
44
|
|
|
@@ -58,12 +59,7 @@ const defaultDependencies: MeshRuntimeDependencies = {
|
|
|
58
59
|
now: Date.now,
|
|
59
60
|
eventId: randomUUID,
|
|
60
61
|
providerEnabled: isProviderEnabled,
|
|
61
|
-
sourceCaptureCurrent: (session, capsule) =>
|
|
62
|
-
const cwd = session.worktree ?? session.cwd;
|
|
63
|
-
try { return Boolean(cwd && workingTreeState(cwd) === 'clean'
|
|
64
|
-
&& inspectRepository(cwd).revision === (capsule.latestCommit ?? capsule.baseSha)); }
|
|
65
|
-
catch { return false; }
|
|
66
|
-
},
|
|
62
|
+
sourceCaptureCurrent: (session, capsule) => sourceCheckoutUnchanged(session.worktree ?? session.cwd, capsule),
|
|
67
63
|
dispatchPreparedTransfer: async (event, session, confirm) =>
|
|
68
64
|
(await import('../../task-network/runtime')).submitNetworkTransfer(event, session, confirm),
|
|
69
65
|
start: async (provider, prompt, cwd, model) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TaskCapsule } from '../../../../../../packages/protocol/schema';
|
|
2
|
+
import { dirtyDiffHash } from '../../admin/capsule';
|
|
3
|
+
import { inspectRepository, workingTreeState } from '../../catalog';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the source checkout is still what its capsule described.
|
|
7
|
+
*
|
|
8
|
+
* A prepared transfer to another computer is confirmed later. Uncommitted work
|
|
9
|
+
* may be left behind — the person was warned — but it has to be the same work
|
|
10
|
+
* they agreed to leave, on the same commit. A checkpoint commit already holds
|
|
11
|
+
* that work, so the source only has to keep its commit.
|
|
12
|
+
*/
|
|
13
|
+
export function sourceCheckoutUnchanged(cwd: string | undefined, capsule: TaskCapsule): boolean {
|
|
14
|
+
if (!cwd) return false;
|
|
15
|
+
try {
|
|
16
|
+
if (inspectRepository(cwd).revision !== capsule.baseSha) return false;
|
|
17
|
+
if (capsule.checkpoint) return true;
|
|
18
|
+
const tree = workingTreeState(cwd);
|
|
19
|
+
if (tree !== (capsule.workingTree ?? 'unknown')) return false;
|
|
20
|
+
return tree !== 'dirty' || dirtyDiffHash(cwd) === capsule.dirtyDiffHash;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
* Handoff readiness.
|
|
3
3
|
*
|
|
4
4
|
* A Task Capsule carries facts, not files: the destination starts a worktree
|
|
5
|
-
* from a commit. Anything still uncommitted stays on the source computer
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* from a commit. Anything still uncommitted stays on the source computer while
|
|
6
|
+
* the Task continues elsewhere. That is the person's call, so it is said out
|
|
7
|
+
* loud — on the phone, here, and in the capsule — rather than refused.
|
|
8
8
|
*/
|
|
9
9
|
import type { ResourceClaim, TaskCapsule } from "../../../../../packages/protocol/schema";
|
|
10
10
|
|
|
11
|
-
export const
|
|
12
|
-
"This task has uncommitted changes.
|
|
11
|
+
export const UNCOMMITTED_WORK_WARNING =
|
|
12
|
+
"This task has uncommitted changes. They stay on this computer; the next agent starts from the last commit.";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
export const
|
|
16
|
-
"GrantTap could not read this working tree, so
|
|
14
|
+
/** "We could not look" is not "clean", so an unread tree is warned about too. */
|
|
15
|
+
export const UNREADABLE_WORKING_TREE_WARNING =
|
|
16
|
+
"GrantTap could not read this working tree, so uncommitted changes may stay on this computer.";
|
|
17
17
|
|
|
18
18
|
export type HandoffCheckId = "repository" | "workingTree" | "claims" | "targetAgent";
|
|
19
19
|
|
|
@@ -48,11 +48,11 @@ function workingTree(capsule?: TaskCapsule): "clean" | "dirty" | "unknown" {
|
|
|
48
48
|
return capsule?.workingTree ?? "unknown";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function workingTreeWarning(capsule?: TaskCapsule): string | undefined {
|
|
52
52
|
switch (workingTree(capsule)) {
|
|
53
|
-
case "clean": return
|
|
54
|
-
case "dirty": return
|
|
55
|
-
default: return
|
|
53
|
+
case "clean": return undefined;
|
|
54
|
+
case "dirty": return UNCOMMITTED_WORK_WARNING;
|
|
55
|
+
default: return UNREADABLE_WORKING_TREE_WARNING;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -68,11 +68,7 @@ export function handoffReadiness(input: HandoffReadinessInput): HandoffReadiness
|
|
|
68
68
|
? `Commit ${capsule.latestCommit ?? capsule.baseSha} in ${capsule.repository}`
|
|
69
69
|
: "No git repository, task, or commit was found for this execution.",
|
|
70
70
|
),
|
|
71
|
-
check(
|
|
72
|
-
"workingTree",
|
|
73
|
-
Boolean(capsule) && workingTree(capsule) === "clean",
|
|
74
|
-
workingTreeDetail(capsule),
|
|
75
|
-
),
|
|
71
|
+
check("workingTree", Boolean(capsule), workingTreeWarning(capsule) ?? "Clean"),
|
|
76
72
|
check(
|
|
77
73
|
"claims",
|
|
78
74
|
!conflict,
|
|
@@ -89,9 +85,11 @@ export function handoffReadiness(input: HandoffReadinessInput): HandoffReadiness
|
|
|
89
85
|
),
|
|
90
86
|
];
|
|
91
87
|
const blocked = checks.find((item) => !item.ready);
|
|
88
|
+
const leftBehind = capsule ? workingTreeWarning(capsule) : undefined;
|
|
92
89
|
// Same module, different file: the merge conflict that has not happened
|
|
93
90
|
// yet. Said out loud, not enforced — two agents can share a module.
|
|
94
|
-
const
|
|
91
|
+
const overlaps = (input.moduleOverlaps ?? []).slice(0, 8).map((claim) =>
|
|
95
92
|
`${claim.ownerSessionId} is working in the same module (${claim.resource}).`);
|
|
93
|
+
const warnings = [...(leftBehind ? [leftBehind] : []), ...overlaps];
|
|
96
94
|
return { ready: !blocked, blockedReason: blocked?.detail, checks, warnings };
|
|
97
95
|
}
|
|
@@ -15,6 +15,10 @@ beside `mesh-publication`. This module uses the repository Apache License 2.0.
|
|
|
15
15
|
`message-delivery/index.ts` reserves the durable delivery lease, then
|
|
16
16
|
emits a non-terminal processing receipt only after workspace, attachment and
|
|
17
17
|
provider validation. Duplicate validated requests replay that receipt without
|
|
18
|
-
starting another provider run.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
starting another provider run. A message is delivered once the agent has
|
|
19
|
+
taken it: `message-delivery/acceptance.ts` reports an immediate failure as that
|
|
20
|
+
failure and accepts a run still going after a short window, so the
|
|
21
|
+
eleven-minute lease only has to cover that window. The answer, or the reason a
|
|
22
|
+
long run stopped, reaches the chat when the run ends; unattended runs are
|
|
23
|
+
bounded at six hours. New-task startup events are status updates; the
|
|
24
|
+
correlated result is terminal. Delivery integration tests live in `tests/delivery`.
|
|
@@ -4,8 +4,6 @@ import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
|
4
4
|
import { pruneAttachments, takeAttachment } from "../../delivery/attachment-store";
|
|
5
5
|
import { sendSessionPayload } from "../../host/session-keys";
|
|
6
6
|
|
|
7
|
-
export const DELIVERY_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
-
|
|
9
7
|
export async function sendDeliveryReceipt(
|
|
10
8
|
client: RelayClient,
|
|
11
9
|
messageId: string,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createCodexSession,
|
|
10
10
|
createCursorSession,
|
|
11
11
|
createGrokSession,
|
|
12
|
+
type ReplyResult,
|
|
12
13
|
} from "../../reply";
|
|
13
14
|
import { acceptInstanceEpoch } from "../../host/instance-epoch";
|
|
14
15
|
import { evaluateCreateTask } from "../../mesh/tasks/create-task";
|
|
@@ -16,7 +17,7 @@ import { recordDelegation } from "../../mesh/handoff/delegation-loop";
|
|
|
16
17
|
import { enqueuePinnedTask } from "../../mesh/tasks/queue";
|
|
17
18
|
import { sendSessionPayload } from "../../host/session-keys";
|
|
18
19
|
import { resolveMessageAttachments } from "./receipts";
|
|
19
|
-
import {
|
|
20
|
+
import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
|
|
20
21
|
|
|
21
22
|
export type TaskCreateAdmission =
|
|
22
23
|
| { status: "accepted" }
|
|
@@ -99,15 +100,16 @@ export async function handleTaskCreate(
|
|
|
99
100
|
cursor: createCursorSession,
|
|
100
101
|
grok: createGrokSession,
|
|
101
102
|
}[agent];
|
|
102
|
-
const
|
|
103
|
-
message.text, message.cwd,
|
|
103
|
+
const run = create(
|
|
104
|
+
message.text, message.cwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model,
|
|
104
105
|
message.operationId,
|
|
105
106
|
);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
107
|
+
const settle = (result: ReplyResult, late: boolean) => result.ok
|
|
108
|
+
? say(result.text, result.sessionId, true)
|
|
109
|
+
: say(late ? `The ${displayName} task stopped: ${result.error}`
|
|
110
|
+
: `Could not create a ${displayName} task: ${result.error}`, undefined, true);
|
|
111
|
+
const early = await acceptWhenStarted(run, (result) => settle(result, true));
|
|
112
|
+
if (early === "started") return { status: "accepted" };
|
|
113
|
+
await settle(early, false);
|
|
114
|
+
return early.ok ? { status: "accepted" } : { status: "rejected", error: "Provider delivery failed." };
|
|
113
115
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createCursorSession,
|
|
11
11
|
createGrokSession,
|
|
12
12
|
deliverToSession,
|
|
13
|
+
type ReplyResult,
|
|
13
14
|
} from "../../reply";
|
|
14
15
|
import { noteDeliveredRun } from "../../mesh/delivery/run-digest";
|
|
15
16
|
import { evaluateCreateTask } from "../../mesh/tasks/create-task";
|
|
@@ -17,7 +18,7 @@ import { sendSessionPayload } from "../../host/session-keys";
|
|
|
17
18
|
import { scanSessions } from "../../sessions";
|
|
18
19
|
import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
|
|
19
20
|
import { resolveMessageAttachments } from "./receipts";
|
|
20
|
-
import {
|
|
21
|
+
import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
|
|
21
22
|
|
|
22
23
|
async function startNewAgentTask(input: {
|
|
23
24
|
client: RelayClient;
|
|
@@ -66,16 +67,17 @@ async function startNewAgentTask(input: {
|
|
|
66
67
|
cursor: createCursorSession,
|
|
67
68
|
grok: createGrokSession,
|
|
68
69
|
}[agent];
|
|
69
|
-
const
|
|
70
|
-
message.text, requestedCwd,
|
|
70
|
+
const run = create(
|
|
71
|
+
message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model, undefined, message.effort,
|
|
71
72
|
);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
const settle = (result: ReplyResult, late: boolean) => result.ok
|
|
74
|
+
? say(result.text, result.sessionId, true)
|
|
75
|
+
: say(late ? `The ${displayName} task stopped: ${result.error}`
|
|
76
|
+
: `Could not create a ${displayName} task: ${result.error}`, undefined, true);
|
|
77
|
+
const early = await acceptWhenStarted(run, (result) => settle(result, true));
|
|
78
|
+
if (early === "started") return "accepted";
|
|
79
|
+
await settle(early, false);
|
|
80
|
+
return early.ok ? "accepted" : "rejected";
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export async function handleUserMessage(
|
|
@@ -125,32 +127,34 @@ export async function handleUserMessage(
|
|
|
125
127
|
// marks the person's bubble, and the next words in the chat are the answer.
|
|
126
128
|
await onProcessing?.();
|
|
127
129
|
const startedAt = Date.now();
|
|
128
|
-
const
|
|
130
|
+
const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
|
|
129
131
|
preferredMcp: message.preferredMcp,
|
|
130
132
|
skill: message.skill,
|
|
131
133
|
model: message.model,
|
|
132
134
|
permissionMode: message.permissionMode,
|
|
133
135
|
effort: message.effort,
|
|
134
136
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await say(result.text, result.sessionId ?? target.sessionId, true);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
137
|
+
const settle = async (result: ReplyResult, late: boolean) => {
|
|
138
|
+
// The run's turns are in the transcript, but a session holding this chat
|
|
139
|
+
// open never sees them; the journal is how it finds out on its next prompt,
|
|
140
|
+
// and the Task carries the same digest as progress.
|
|
141
|
+
const noted = noteDeliveredRun({
|
|
142
|
+
session: target,
|
|
143
|
+
prompt: message.text,
|
|
144
|
+
result,
|
|
145
|
+
startedAt,
|
|
146
|
+
endedAt: Date.now(),
|
|
147
|
+
});
|
|
148
|
+
if (noted?.event) {
|
|
149
|
+
await sendSessionPayload(client, noted.event, noted.event.taskId, "phone", { ttlMs: 24 * 60 * 60_000 })
|
|
150
|
+
.catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
if (result.ok) await say(result.text, result.sessionId ?? target.sessionId, true);
|
|
153
|
+
else await say(late ? `The run stopped: ${result.error}` : `Could not deliver the message: ${result.error}`,
|
|
154
|
+
target.sessionId, true);
|
|
155
|
+
};
|
|
156
|
+
const early = await acceptWhenStarted(run, (result) => settle(result, true));
|
|
157
|
+
if (early === "started") return "accepted";
|
|
158
|
+
await settle(early, false);
|
|
159
|
+
return early.ok ? "accepted" : "rejected";
|
|
156
160
|
}
|
|
@@ -5,6 +5,9 @@ sequence for non-correlated phone messages. Validation happens before the first
|
|
|
5
5
|
processing receipt, and duplicate validated messages never start another run.
|
|
6
6
|
Terminal responses remain correlated to the original message id.
|
|
7
7
|
|
|
8
|
+
`acceptance.ts` decides when a phone message counts as delivered: when the agent
|
|
9
|
+
has taken it, not when its whole turn ends. Tests live in `tests/acceptance.test.ts`.
|
|
10
|
+
|
|
8
11
|
Integration tests: `tests/delivery/processing-receipt.test.ts`; attachment rejection
|
|
9
12
|
coverage: `tests/attachment/attachment-uploads.test.ts`. See the monitor README.
|
|
10
13
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a message from the phone counts as delivered.
|
|
3
|
+
*
|
|
4
|
+
* Delivery used to mean "the agent finished its whole turn within ten
|
|
5
|
+
* minutes": a longer turn was killed at the deadline and the person's bubble
|
|
6
|
+
* turned "Not delivered" although the agent had taken the message and was
|
|
7
|
+
* working on it. Delivered now means the agent took it. A run that fails at
|
|
8
|
+
* once (paused, transferred, a CLI that will not start) is still that
|
|
9
|
+
* failure; a run that is still going after a short window is delivered, and
|
|
10
|
+
* its answer — or the reason it stopped — reaches the chat when it ends.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Long enough for a provider CLI to refuse at once, short enough not to keep the phone waiting. */
|
|
14
|
+
export const ACCEPTANCE_WINDOW_MS = 15_000;
|
|
15
|
+
|
|
16
|
+
/** A safety bound for an unattended turn; the person stops a run with Stop, not with a deadline. */
|
|
17
|
+
export const PHONE_RUN_LIMIT_MS = 6 * 60 * 60_000;
|
|
18
|
+
|
|
19
|
+
export async function acceptWhenStarted<T>(
|
|
20
|
+
run: Promise<T>,
|
|
21
|
+
finish: (result: T) => Promise<unknown>,
|
|
22
|
+
windowMs = ACCEPTANCE_WINDOW_MS,
|
|
23
|
+
): Promise<T | "started"> {
|
|
24
|
+
let timer: NodeJS.Timeout | undefined;
|
|
25
|
+
const started = new Promise<"started">((resolve) => {
|
|
26
|
+
timer = setTimeout(() => resolve("started"), windowMs);
|
|
27
|
+
});
|
|
28
|
+
const first = await Promise.race([run, started]);
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (first !== "started") return first;
|
|
31
|
+
void run.then(finish).catch(() => {});
|
|
32
|
+
return "started";
|
|
33
|
+
}
|
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.71
|
|
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.71\";\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.71";
|
|
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
|
@@ -218,8 +218,9 @@ export const MeshHandoffPrepare = z.object({
|
|
|
218
218
|
userComment: Detail.optional(),
|
|
219
219
|
createdAt: z.number().nonnegative(),
|
|
220
220
|
/**
|
|
221
|
-
* Commit uncommitted work to a checkpoint branch first, so the
|
|
222
|
-
*
|
|
221
|
+
* Commit uncommitted work to a checkpoint branch first, so the work travels
|
|
222
|
+
* with the Task instead of staying behind. The branch is local: GrantTap
|
|
223
|
+
* never pushes.
|
|
223
224
|
*/
|
|
224
225
|
checkpoint: z.boolean().optional(),
|
|
225
226
|
/**
|