granttap-mcp 0.8.67 → 0.8.68

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.
Files changed (153) hide show
  1. package/apps/bridge/src/bin/hooks/claude-prompt-hook.ts +7 -1
  2. package/apps/bridge/src/bin/runtime/monitor.ts +11 -0
  3. package/apps/bridge/src/capabilities/index.ts +1 -0
  4. package/apps/bridge/src/capabilities/routing/index.ts +28 -0
  5. package/apps/bridge/src/mesh/admin/convergence.ts +1 -0
  6. package/apps/bridge/src/mesh/catalog/binding-state.ts +3 -1
  7. package/apps/bridge/src/mesh/catalog/index.ts +1 -0
  8. package/apps/bridge/src/mesh/identity/call-scope.ts +1 -1
  9. package/apps/bridge/src/mesh/runtime/dependencies.ts +8 -0
  10. package/apps/bridge/src/mesh/runtime/graph/README.md +26 -0
  11. package/apps/bridge/src/mesh/runtime/graph/analysis.ts +3 -1
  12. package/apps/bridge/src/mesh/runtime/graph/binding-sync.ts +6 -2
  13. package/apps/bridge/src/mesh/runtime/graph/checkout-identity.ts +29 -0
  14. package/apps/bridge/src/mesh/runtime/handoff.ts +45 -17
  15. package/apps/bridge/src/mesh/runtime/index.ts +64 -7
  16. package/apps/bridge/src/mesh/runtime/prepared-transfer/index.ts +27 -0
  17. package/apps/bridge/src/mesh/runtime/snapshot/enriched.ts +3 -1
  18. package/apps/bridge/src/mesh/runtime/worktree.ts +25 -4
  19. package/apps/bridge/src/mesh/store/base.ts +6 -0
  20. package/apps/bridge/src/mesh/store/index.ts +14 -23
  21. package/apps/bridge/src/mesh/store/receipts/index.ts +57 -0
  22. package/apps/bridge/src/mesh/store/sync.ts +2 -101
  23. package/apps/bridge/src/mesh/tasks/state.ts +23 -3
  24. package/apps/bridge/src/monitor/handlers/inbound.ts +21 -10
  25. package/apps/bridge/src/monitor/handlers/session-commands.ts +15 -83
  26. package/apps/bridge/src/monitor/handlers/task-create.ts +5 -1
  27. package/apps/bridge/src/monitor/handlers/user-message.ts +15 -28
  28. package/apps/bridge/src/monitor/support/runtime.ts +2 -0
  29. package/apps/bridge/src/policy/effective-action.ts +8 -0
  30. package/apps/bridge/src/reply/index.ts +31 -7
  31. package/apps/bridge/src/sessions/cursor/activity.ts +8 -7
  32. package/apps/bridge/src/sessions/index.ts +5 -2
  33. package/apps/bridge/src/sessions/scan/codex/parse.ts +4 -0
  34. package/apps/bridge/src/sessions/scan/grok.ts +9 -5
  35. package/apps/bridge/src/task-control/README.md +13 -0
  36. package/apps/bridge/src/task-control/index.ts +72 -0
  37. package/apps/bridge/src/task-dispatch/README.md +22 -0
  38. package/apps/bridge/src/task-dispatch/inbox.ts +168 -0
  39. package/apps/bridge/src/task-dispatch/index.ts +4 -0
  40. package/apps/bridge/src/task-dispatch/journal.ts +65 -0
  41. package/apps/bridge/src/task-dispatch/receipts/index.ts +54 -0
  42. package/apps/bridge/src/task-dispatch/records.ts +30 -0
  43. package/apps/bridge/src/task-dispatch/sender/index.ts +101 -0
  44. package/apps/bridge/src/task-dispatch/sender/store.ts +91 -0
  45. package/apps/bridge/src/task-dispatch/worker/index.ts +120 -0
  46. package/apps/bridge/src/task-dispatch/worker/monitor.ts +62 -0
  47. package/apps/bridge/src/task-dispatch/worker/remote.ts +20 -0
  48. package/apps/bridge/src/task-network/README.md +34 -0
  49. package/apps/bridge/src/task-network/configuration/README.md +18 -0
  50. package/apps/bridge/src/task-network/configuration/ciphertext.ts +36 -0
  51. package/apps/bridge/src/task-network/configuration/runtime.ts +43 -0
  52. package/apps/bridge/src/task-network/configuration/sync.ts +35 -0
  53. package/apps/bridge/src/task-network/index.ts +95 -0
  54. package/apps/bridge/src/task-network/runtime/configuration.ts +34 -0
  55. package/apps/bridge/src/task-network/runtime/index.ts +3 -0
  56. package/apps/bridge/src/task-network/runtime/monitor.ts +53 -0
  57. package/apps/bridge/src/task-network/runtime/preparation.ts +119 -0
  58. package/apps/bridge/src/task-network/runtime/receiver.ts +114 -0
  59. package/apps/bridge/src/task-network/source/index.ts +160 -0
  60. package/apps/bridge/src/task-network/source/runtime.ts +62 -0
  61. package/apps/bridge/src/task-network/storage/incoming.ts +109 -0
  62. package/apps/bridge/src/task-network/storage/outgoing.ts +97 -0
  63. package/apps/bridge/src/task-network/storage/private-file.ts +25 -0
  64. package/apps/bridge/src/task-network/transport/ciphertext.ts +36 -0
  65. package/apps/bridge/src/task-network/transport/http.ts +62 -0
  66. package/apps/bridge/src/task-network/transport/receive.ts +30 -0
  67. package/apps/bridge/src/task-routing/README.md +18 -0
  68. package/apps/bridge/src/task-routing/admission/index.ts +67 -0
  69. package/apps/bridge/src/task-routing/index.ts +92 -0
  70. package/apps/bridge/src/task-routing/observed-turn/codex.ts +48 -0
  71. package/apps/bridge/src/task-routing/observed-turn/index.ts +15 -0
  72. package/apps/bridge/src/task-routing/requests/index.ts +123 -0
  73. package/apps/bridge/src/task-routing/requests/processor.ts +58 -0
  74. package/apps/bridge/src/task-routing/resources/README.md +19 -0
  75. package/apps/bridge/src/task-routing/resources/hardware.ts +56 -0
  76. package/apps/bridge/src/task-routing/resources/index.ts +31 -0
  77. package/apps/bridge/src/task-routing/resources/observations.ts +53 -0
  78. package/apps/bridge/src/task-routing/resources/reporter.ts +60 -0
  79. package/apps/bridge/src/task-routing/resources/runtime.ts +30 -0
  80. package/apps/bridge/src/task-routing/resources/transport.ts +52 -0
  81. package/apps/bridge/src/task-routing/runtime/admission.ts +37 -0
  82. package/apps/bridge/src/task-routing/runtime/index.ts +61 -0
  83. package/apps/bridge/src/task-routing/runtime/observation.ts +39 -0
  84. package/apps/bridge/src/task-routing/runtime/router.ts +84 -0
  85. package/apps/bridge/src/task-transfer/README.md +16 -0
  86. package/apps/bridge/src/task-transfer/context/archive.ts +10 -0
  87. package/apps/bridge/src/task-transfer/context/images.ts +32 -0
  88. package/apps/bridge/src/task-transfer/context/materialize.ts +36 -0
  89. package/apps/bridge/src/task-transfer/context.ts +57 -0
  90. package/apps/bridge/src/task-transfer/continuation/README.md +8 -0
  91. package/apps/bridge/src/task-transfer/continuation/index.ts +124 -0
  92. package/apps/bridge/src/task-transfer/index.ts +111 -0
  93. package/apps/bridge/src/task-transfer/persistence.ts +109 -0
  94. package/apps/bridge/src/task-transfer/projection/index.ts +29 -0
  95. package/apps/bridge/src/task-transfer/projection/storage.ts +68 -0
  96. package/apps/bridge/src/task-transfer/records.ts +16 -0
  97. package/apps/bridge/src/task-transfer/runtime/README.md +7 -0
  98. package/apps/bridge/src/task-transfer/runtime/commands.ts +12 -0
  99. package/apps/bridge/src/task-transfer/runtime/continuation.ts +14 -0
  100. package/apps/bridge/src/task-transfer/runtime/index.ts +3 -0
  101. package/apps/bridge/src/task-transfer/runtime/local.ts +10 -0
  102. package/apps/bridge/src/transcript-history/images/native.ts +5 -1
  103. package/apps/bridge/src/transcript-history/index.ts +10 -3
  104. package/apps/mcp/src/desktop/delivery/README.md +5 -0
  105. package/apps/mcp/src/desktop/delivery/attachments.ts +19 -4
  106. package/apps/mcp/src/desktop/delivery/task-handoff.ts +1 -0
  107. package/apps/mcp/src/desktop/delivery/task-scope.ts +26 -0
  108. package/apps/mcp/src/desktop/delivery/task-send.ts +17 -26
  109. package/apps/mcp/src/desktop/engine-bridge.ts +10 -3
  110. package/apps/mcp/src/desktop/native-access/README.md +4 -0
  111. package/apps/mcp/src/desktop/native-access/routes.ts +1 -1
  112. package/apps/mcp/src/desktop/operations/index.ts +11 -0
  113. package/apps/mcp/src/desktop/project-transport/README.md +13 -0
  114. package/apps/mcp/src/desktop/project-transport/index.ts +71 -0
  115. package/apps/mcp/src/desktop/task-activity-runner.ts +8 -1
  116. package/apps/mcp/src/desktop/task-activity-worker.ts +4 -0
  117. package/apps/mcp/src/desktop/task-control/README.md +26 -0
  118. package/apps/mcp/src/desktop/task-control/capabilities.ts +29 -0
  119. package/apps/mcp/src/desktop/task-control/index.ts +30 -0
  120. package/apps/mcp/src/desktop/task-control/preferences.ts +57 -0
  121. package/apps/mcp/src/desktop/task-queue/README.md +14 -0
  122. package/apps/mcp/src/desktop/task-queue/index.ts +63 -0
  123. package/apps/mcp/src/desktop/task-transfer/README.md +7 -0
  124. package/apps/mcp/src/desktop/task-transfer/index.ts +27 -0
  125. package/apps/mcp/src/http/create-server.ts +2 -0
  126. package/apps/mcp/src/mcp-tools/routing/index.ts +27 -0
  127. package/apps/mcp/src/status/desktop-status.ts +2 -0
  128. package/cursor-plugin/README.md +1 -1
  129. package/cursor-plugin/mcp.json +1 -1
  130. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  131. package/package.json +1 -1
  132. package/packages/core/README.md +24 -0
  133. package/packages/core/relay-client-types.ts +3 -0
  134. package/packages/core/relay-client.ts +39 -97
  135. package/packages/core/relay-inbound/index.ts +50 -0
  136. package/packages/core/replay-store.ts +29 -0
  137. package/packages/core/socket-lifecycle/index.ts +2 -0
  138. package/packages/core/socket-lifecycle/upgrade-headers.ts +11 -0
  139. package/packages/core/store-file-lock/README.md +11 -0
  140. package/packages/core/store-file-lock/index.ts +103 -0
  141. package/packages/core/task-dispatch/index.ts +37 -0
  142. package/packages/core/task-job-http/README.md +9 -0
  143. package/packages/core/task-job-http/index.ts +31 -0
  144. package/packages/protocol/messages/mesh/events.ts +6 -0
  145. package/packages/protocol/messages/mesh/tasks.ts +2 -0
  146. package/packages/protocol/messages/sessions/index.ts +4 -0
  147. package/packages/protocol/schema.ts +11 -0
  148. package/packages/protocol/task-dispatch/README.md +17 -0
  149. package/packages/protocol/task-dispatch/index.ts +40 -0
  150. package/packages/protocol/task-network/index.ts +92 -0
  151. package/packages/protocol/task-resources/index.ts +33 -0
  152. package/packages/protocol/task-transfer/README.md +7 -0
  153. package/packages/protocol/task-transfer/index.ts +18 -0
@@ -5,11 +5,13 @@
5
5
  * Registered in ~/.claude/settings.json so that when a person submits a
6
6
  * prompt, this program adds what the chat could not know by itself: the
7
7
  * background runs that answered phone messages in it, and the Mesh brief for
8
- * its Task. It never blocks a prompt — anything wrong, and it adds nothing.
8
+ * its Task. Transferred chats require explicit continuation; ordinary prompts retain the existing context behavior.
9
9
  *
10
10
  * Reads: UserPromptSubmit JSON on stdin ({ session_id, prompt, cwd, ... })
11
11
  * Writes: { hookSpecificOutput: { hookEventName, additionalContext } } on stdout
12
12
  */
13
+ import { localTransferContinuations } from "../../task-transfer";
14
+ import { localRoutingRequests } from '../../task-routing/requests';
13
15
  import { promptContext } from "../../mesh/context/prompt";
14
16
 
15
17
  async function readStdin(): Promise<string> {
@@ -30,6 +32,10 @@ async function main(): Promise<void> {
30
32
  return;
31
33
  }
32
34
  const sessionId = typeof input.session_id === "string" ? input.session_id.trim() : "";
35
+ const transferred = localTransferContinuations().blockReason(sessionId) ?? localRoutingRequests().blockReason(sessionId);
36
+ if (transferred) {
37
+ process.stdout.write(JSON.stringify({ decision: "block", reason: transferred })); return;
38
+ }
33
39
  const text = promptContext(sessionId);
34
40
  if (!text) return;
35
41
  process.stdout.write(JSON.stringify({
@@ -12,11 +12,16 @@ import { applyNetworkRoute } from "../../device-network/settings";
12
12
  import { startEndpointPublisher } from "../../device-network/directory";
13
13
  import { managedAdmissionOptions } from "../../managed-admission";
14
14
 
15
+ import { startTaskDispatchMonitor } from "../../task-dispatch/worker/monitor";
16
+ import { startTaskNetworkMonitor } from '../../task-network/runtime';
17
+
15
18
  let client: RelayClient;
16
19
  let monitor: ReturnType<typeof startSessionMonitor>;
17
20
  const engine = new EngineSupervisor();
18
21
  let stopping = false;
19
22
  let stopEndpointPublisher = () => {};
23
+ let stopTaskDispatch = () => {};
24
+ let stopTaskNetwork = () => {};
20
25
 
21
26
  try {
22
27
  const cfg = applyNetworkRoute(loadConfig(machineConfigPath()));
@@ -27,6 +32,10 @@ try {
27
32
  peerAllowed: controllerPeerGate(cfg),
28
33
  });
29
34
  monitor = startSessionMonitor(client);
35
+ stopTaskDispatch = startTaskDispatchMonitor({ config: cfg, client,
36
+ isLeader: monitor.isLeader, peerAllowed: controllerPeerGate(cfg), publish: monitor.publish });
37
+ stopTaskNetwork = startTaskNetworkMonitor({ config: cfg, client,
38
+ isLeader: monitor.isLeader, peerAllowed: controllerPeerGate(cfg), publish: monitor.publish });
30
39
  stopEndpointPublisher = startEndpointPublisher(cfg);
31
40
  } catch (error) {
32
41
  process.stderr.write(
@@ -38,6 +47,8 @@ try {
38
47
  const stop = (): void => {
39
48
  if (stopping) return;
40
49
  stopping = true;
50
+ stopTaskDispatch();
51
+ stopTaskNetwork();
41
52
  monitor.close();
42
53
  stopEndpointPublisher();
43
54
  client.close();
@@ -3,6 +3,7 @@ import { descriptorsForProvider, descriptorsForSession, mcpConfigDigest } from "
3
3
  import { cachedMetadata, refreshDescriptorMetadata } from "./metadata";
4
4
 
5
5
  export { projectSharedSkills, workspaceSkills } from "./skills";
6
+ export { deliveryRoutingError } from "./routing";
6
7
  export { mcpConfigDigest } from "./descriptors";
7
8
 
8
9
  /** Resolve real MCP initialization metadata and cache it for a session. */
@@ -0,0 +1,28 @@
1
+ import type { SessionInfo } from "../../../../../packages/protocol/schema";
2
+ import { loadRuntimeConfig } from "../../config";
3
+ import type { DeliveryOptions } from "../../reply/payload/types";
4
+ import { mcpServersForSession, workspaceSkills } from "..";
5
+
6
+ /** Validate explicit routing against current machine and Task capability rules. */
7
+ export function deliveryRoutingError(target: SessionInfo, options: DeliveryOptions): string | undefined {
8
+ const runtime = loadRuntimeConfig();
9
+ const settings = runtime.providerSettings;
10
+ if (target.agent in settings && settings[target.agent as keyof typeof settings] === false) {
11
+ return "This agent is disabled in GrantTap Settings.";
12
+ }
13
+ if (options.preferredMcp) {
14
+ const disabled = runtime.sessionMcpDisabled[target.sessionId] ?? [];
15
+ if (!mcpServersForSession(target, disabled).some(server => server.name === options.preferredMcp && server.allowed)) {
16
+ return "The selected MCP server is not allowed for this task.";
17
+ }
18
+ }
19
+ if (options.skill) {
20
+ if (!workspaceSkills(target.cwd).some(skill => skill.name === options.skill)) {
21
+ return "The selected project skill is no longer available in this task's folder.";
22
+ }
23
+ if ((runtime.sessionSkillsDisabled[target.sessionId] ?? []).includes(options.skill)) {
24
+ return "The selected project skill is disabled for this task.";
25
+ }
26
+ }
27
+ return undefined;
28
+ }
@@ -54,6 +54,7 @@ export function preferExecution(
54
54
  if ((current.endedAt != null) !== (incoming.endedAt != null)) {
55
55
  const ended = current.endedAt != null ? current : incoming;
56
56
  const live = current.endedAt != null ? incoming : current;
57
+ if (live.continuedAt != null && live.continuedAt > ended.endedAt!) return live;
57
58
  return { ...live, endedAt: ended.endedAt };
58
59
  }
59
60
  const currentAt = current.updatedAt ?? current.startedAt;
@@ -81,7 +81,9 @@ export function workspaceForRepository(
81
81
  .filter((task) => projectIds.has(task.projectId)).map((task) => task.taskId));
82
82
  const local = state.executions.find((item) =>
83
83
  taskIds.has(item.taskId) && (computerId == null || item.computerId === computerId));
84
- if (computerId != null) return local?.worktree ?? local?.workspace;
84
+ if (computerId != null) return local?.worktree ?? local?.workspace
85
+ ?? state.bindings.find(binding => binding.endpointId === computerId
86
+ && binding.repositoryId === canonicalRepositoryId && binding.available)?.localPathHint;
85
87
  return local?.worktree ?? local?.workspace
86
88
  ?? state.projects.find((item) => projectIds.has(item.projectId))?.repositoryRoot;
87
89
  }
@@ -224,6 +224,7 @@ function linkSession(input: {
224
224
  workspace: cwd,
225
225
  repositoryId: repository.canonicalRepositoryId,
226
226
  activeAt: session.lastActivityAt,
227
+ state: session.state, continuedAt: session.transfer?.continuedAt,
227
228
  branch: repository.branch ?? session.branch,
228
229
  worktree: repository.worktree,
229
230
  uncommitted: hasUncommittedWork(repository.worktree ?? cwd),
@@ -49,7 +49,7 @@ function canonical(value: unknown): unknown {
49
49
  export function grantTapToolName(rawToolName: unknown): string | undefined {
50
50
  const raw = String(rawToolName ?? "").trim();
51
51
  const tool = raw.includes("__") ? raw.split("__").at(-1)! : raw;
52
- return ["notify", "connect", "ask", "ask_yes_no"].includes(tool) ? tool : undefined;
52
+ return ["notify", "connect", "ask", "ask_yes_no", "offload_task"].includes(tool) ? tool : undefined;
53
53
  }
54
54
 
55
55
  export function meshCallKey(tool: string, args: unknown): string {
@@ -5,6 +5,7 @@ import type {
5
5
  MeshProvider,
6
6
  MeshSnapshot,
7
7
  SessionInfo,
8
+ TaskCapsule,
8
9
  } from "../../../../../packages/protocol/schema";
9
10
  import type { createCodexSession, deliverToSession } from "../../reply";
10
11
  import type { MeshStore } from "../store";
@@ -15,6 +16,12 @@ type WorktreeResult = NonNullable<ReturnType<typeof createHandoffWorktree>>;
15
16
 
16
17
  export type MeshRuntimeDependencies = {
17
18
  store: () => MeshStore;
19
+ continuations?: () => import("../../task-transfer").TransferContinuations;
20
+ transfers?: () => import('../../task-transfer').PreparedTaskTransfers;
21
+ captureTransferContext?: typeof import('../../task-transfer').captureTransferContext;
22
+ sourceCaptureCurrent?: (session: SessionInfo, capsule: TaskCapsule) => boolean;
23
+ dispatchPreparedTransfer?: (event: MeshEvent, session: SessionInfo,
24
+ confirmContext: () => Promise<boolean>) => Promise<boolean>;
18
25
  sessions: () => SessionInfo[];
19
26
  /** Observed native configuration for a session on this endpoint. */
20
27
  capabilityInventory?: (session: SessionInfo) => SessionInfo;
@@ -34,6 +41,7 @@ export type MeshRuntimeDependencies = {
34
41
  taskId: string,
35
42
  provider: MeshProvider,
36
43
  revision: string,
44
+ operationId?: string,
37
45
  ) => WorktreeResult | undefined | null;
38
46
  hasCommit: (repository: string, revision: string) => boolean;
39
47
  /** Publish the branch a capsule names, when the person asked for it. */
@@ -0,0 +1,26 @@
1
+ # Repository graph refresh
2
+
3
+ Public entry point: `analysis.ts` for explicit builds; `binding-sync.ts` for
4
+ verified local admission and recovery; `checkout-identity.ts` for checkout scope.
5
+
6
+ A remote identity must match the checkout's observed Git origin. A `local:`
7
+ identity must resolve to the exact Git root it names. Gaining an origin does not
8
+ invalidate an existing local-directory binding or move it to another Project.
9
+ Local bindings are admitted to Engine as path identities without a canonical
10
+ remote claim. Changed remote identities and local paths to different checkouts
11
+ remain rejected. Neither Task identity nor Project membership is rewritten.
12
+
13
+ After admission, `graphAnalysisBindings` selects one graph per endpoint and real
14
+ checkout path, preferring an admitted canonical binding over a duplicate local
15
+ alias. Failed bindings remain visible with their original error. Both the Mac
16
+ projection and phone refresh use this rule. The graph contains actual Engine
17
+ analysis; unavailable analysis is never synthesized as a successful graph.
18
+
19
+ Behavior tests: `tests/`, root `tests/graph/graph-binding-sync.test.ts`, and
20
+ `apps/mcp/src/desktop/projection/tests/graph-refresh.test.ts`. The optional
21
+ `engine-selection-live.test.ts` uses `GRANTTAP_ENGINE_TEST_BINARY` to verify the
22
+ actual native scanner respects `.git/info/exclude` and retains untracked source.
23
+
24
+ ## License
25
+
26
+ Apache License 2.0. See [LICENSE](../../../../../../LICENSE).
@@ -5,6 +5,7 @@ import { sendMeshPayload } from "../../../host/session-keys";
5
5
  import { publishedCatalog } from "../../../session-catalog";
6
6
  import { meshSnapshot, meshSnapshots } from "..";
7
7
  import { refreshLocalGraphBindings } from "./binding-sync";
8
+ import { graphAnalysisBindings } from "./checkout-identity";
8
9
 
9
10
  /** A person's explicit Graph refresh bypasses background job backoff. */
10
11
  type GraphAnalysisDependencies = {
@@ -48,7 +49,8 @@ export async function analyzeProjectGraphNow(
48
49
  const rejected = new Set(failures.map(report => report.repositoryId));
49
50
  const [backbone, repositoryGraphs] = await Promise.all([
50
51
  dependencies.backbone(projectId),
51
- dependencies.graphs(projectId, (current.bindings ?? []).filter(binding => !rejected.has(binding.repositoryId)), { background: false }),
52
+ dependencies.graphs(projectId, graphAnalysisBindings((current.bindings ?? [])
53
+ .filter(binding => !rejected.has(binding.repositoryId))), { background: false }),
52
54
  ]);
53
55
  const observed = [...repositoryGraphs, ...failures];
54
56
  const reports = observed.length > 0 ? observed : [{
@@ -3,6 +3,7 @@ import type { MeshSnapshot, ProjectRepositoryGraph } from "../../../../../../pac
3
3
  import { queueProjectBindingSync } from "../../../engine/runtime/engine-projects";
4
4
  import { inspectRepository } from "../../catalog";
5
5
  import { computerId } from "../../identity/computer";
6
+ import { matchesLocalCheckout } from "./checkout-identity";
6
7
 
7
8
  /** Re-admit this computer's verified checkouts before an explicit Graph build. */
8
9
  export async function refreshLocalGraphBindings(
@@ -21,8 +22,9 @@ export async function refreshLocalGraphBindings(
21
22
  try {
22
23
  const path = binding.localPathHint!;
23
24
  const facts = dependencies.inspect(path);
25
+ const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
24
26
  if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)
25
- || facts.canonicalRepositoryId !== binding.repositoryId) {
27
+ || (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity)) {
26
28
  failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
27
29
  revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
28
30
  analysisErrorCode: facts.worktree ? "REPOSITORY_IDENTITY_MISMATCH" : "REPOSITORY_IDENTITY_UNVERIFIED",
@@ -32,7 +34,9 @@ export async function refreshLocalGraphBindings(
32
34
  const admitted = await dependencies.sync(snapshot.project, {
33
35
  summary: binding,
34
36
  localRoot: facts.root,
35
- canonicalRemote: facts.baseRemote,
37
+ // A local binding is scoped by its verified path. Do not relabel it as
38
+ // a remote repository or move it into that repository's Project scope.
39
+ canonicalRemote: pathIdentity ? undefined : facts.baseRemote,
36
40
  lastSeenAt: Date.now(),
37
41
  });
38
42
  if (!admitted) failures.push(admissionFailure(snapshot, binding));
@@ -0,0 +1,29 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import type { ProjectBindingSummary } from "../../../../../../packages/protocol/schema";
4
+
5
+ /** Local identities name an exact directory, even after that directory gains an origin. */
6
+ export function matchesLocalCheckout(repositoryId: string, root: string): boolean {
7
+ if (!repositoryId.startsWith("local:")) return false;
8
+ const path = repositoryId.slice("local:".length);
9
+ if (!isAbsolute(path)) return false;
10
+ try { return realpathSync(path) === realpathSync(root); }
11
+ catch { return false; }
12
+ }
13
+
14
+ /** Once admitted, one checkout contributes one graph to this Mesh. */
15
+ export function graphAnalysisBindings(bindings: ProjectBindingSummary[]): ProjectBindingSummary[] {
16
+ const selected = new Map<string, ProjectBindingSummary>();
17
+ for (const binding of bindings) {
18
+ let path: string | undefined;
19
+ if (binding.available && binding.localPathHint) {
20
+ try { path = realpathSync(binding.localPathHint); } catch { /* Keep an unavailable checkout distinct. */ }
21
+ }
22
+ const key = JSON.stringify([binding.endpointId, path ?? binding.repositoryId]);
23
+ const previous = selected.get(key);
24
+ if (!previous || (previous.repositoryId.startsWith("local:") && !binding.repositoryId.startsWith("local:"))) {
25
+ selected.set(key, binding);
26
+ }
27
+ }
28
+ return [...selected.values()];
29
+ }
@@ -6,8 +6,9 @@
6
6
  * catalog, snapshots, and inbound routing.
7
7
  */
8
8
  import type { RelayClient } from "../../../../../packages/core/relay-client";
9
- import type { MeshEvent, MeshHandoffPrepare, TaskCapsule } from "../../../../../packages/protocol/schema";
10
- import { handoffReceipt } from "../handoff";
9
+ import type { MeshEvent, MeshHandoffPrepare, TaskCapsule, SessionInfo } from "../../../../../packages/protocol/schema";
10
+ import { handoffReceipt, capsuleHash } from "../handoff";
11
+ import { localTaskTransfers, localTransferContinuations, transferDigest } from "../../task-transfer";
11
12
  import type { MeshRuntimeDependencies } from "./dependencies";
12
13
 
13
14
  function capsulePrompt(capsule: TaskCapsule): string {
@@ -132,23 +133,42 @@ async function acceptHandoff(
132
133
  + "or hand off from a commit this computer already has.",
133
134
  );
134
135
  }
135
- const worktree = deps.worktree(
136
- repository,
137
- event.taskId,
138
- capsule.targetProvider,
139
- revision,
140
- );
136
+ const transfers = capsule.launch === "on_message" ? (deps.transfers ?? localTaskTransfers)() : undefined;
137
+ const previous = transfers?.forEvent(event.eventId);
138
+ if (previous && (previous.projectId !== event.projectId
139
+ || previous.sourceSessionId !== event.sourceSessionId
140
+ || capsuleHash(previous.capsule) !== capsuleHash(capsule))) {
141
+ return rejectHandoff(deps, client, event, "This transfer id already names different work.");
142
+ }
143
+ if (transfers && !previous && (!event.payload.transferContext || !capsule.title
144
+ || transferDigest(event.payload.transferContext) !== capsule.contextHash)) {
145
+ return rejectHandoff(deps, client, event, "The complete transfer context could not be confirmed.");
146
+ }
147
+ const worktree = previous ? { path: previous.workspace, branch: previous.branch }
148
+ : transfers ? deps.worktree(repository, event.taskId, capsule.targetProvider, revision, event.eventId)
149
+ : deps.worktree(repository, event.taskId, capsule.targetProvider, revision);
141
150
  if (!worktree) return rejectHandoff(deps, client, event, "A separate handoff worktree could not be created.");
142
- const result = await deps.start(
143
- capsule.targetProvider, capsulePrompt(capsule), worktree.path, capsule.targetModel,
144
- );
145
- if (!result.ok || !result.sessionId) {
146
- const reason = result.ok ? "The target agent did not create a session." : result.error;
147
- return rejectHandoff(deps, client, event, reason);
151
+ let targetSessionId: string;
152
+ if (transfers) {
153
+ try {
154
+ targetSessionId = previous?.sessionId ?? transfers.prepare({ eventId: event.eventId,
155
+ projectId: event.projectId, sourceSessionId: event.sourceSessionId,
156
+ title: capsule.title!, capsule, workspace: worktree.path, branch: worktree.branch,
157
+ context: event.payload.transferContext!, acceptedAt: deps.now() }).sessionId;
158
+ } catch {
159
+ return rejectHandoff(deps, client, event, "The complete transfer could not be saved on this computer.");
160
+ }
161
+ } else {
162
+ const result = await deps.start(capsule.targetProvider, capsulePrompt(capsule), worktree.path, capsule.targetModel);
163
+ if (!result.ok || !result.sessionId) {
164
+ const reason = result.ok ? "The target agent did not create a session." : result.error;
165
+ return rejectHandoff(deps, client, event, reason);
166
+ }
167
+ targetSessionId = result.sessionId;
148
168
  }
149
169
  store.linkExecution({
150
170
  taskId: event.taskId,
151
- sessionId: result.sessionId,
171
+ sessionId: targetSessionId,
152
172
  provider: capsule.targetProvider,
153
173
  computerId: deps.computer(),
154
174
  workspace: worktree.path,
@@ -156,10 +176,18 @@ async function acceptHandoff(
156
176
  worktree: worktree.path,
157
177
  startedAt: deps.now(),
158
178
  });
159
- const receipt = handoffReceipt(capsule, event.sourceSessionId, result.sessionId, deps.now());
179
+ const receipt = handoffReceipt(capsule, event.sourceSessionId, targetSessionId, previous?.acceptedAt ?? deps.now());
180
+ if (transfers) {
181
+ receipt.prepared = true;
182
+ if (capsule.sourceComputer === deps.computer()) (deps.continuations ?? localTransferContinuations)().record({
183
+ sourceSessionId: receipt.sourceSessionId, targetSessionId: receipt.targetSessionId,
184
+ taskId: receipt.taskId, projectId: event.projectId, capsuleHash: receipt.capsuleHash,
185
+ acceptedAt: receipt.acceptedAt, queuedAt: event.createdAt,
186
+ }, (deps.transfers ?? localTaskTransfers)().nativeSession({ sessionId: receipt.sourceSessionId } as SessionInfo).sessionId);
187
+ }
160
188
  store.recordReceipt(receipt);
161
189
  const accepted = responseEvent({
162
- deps, source: event, sourceSessionId: result.sessionId, eventType: "HANDOFF_ACCEPTED",
190
+ deps, source: event, sourceSessionId: targetSessionId, eventType: "HANDOFF_ACCEPTED",
163
191
  payload: { receipt },
164
192
  });
165
193
  store.acceptEvent(accepted);
@@ -10,6 +10,7 @@ import type {
10
10
  SessionInfo,
11
11
  TaskCapsule,
12
12
  } from "../../../../../packages/protocol/schema";
13
+ import { captureTransferContext, localTransferContinuations, localTaskTransfers } from "../../task-transfer";
13
14
  import { configDir } from "../../config";
14
15
  import {
15
16
  createClaudeSession,
@@ -20,7 +21,7 @@ import {
20
21
  } from "../../reply";
21
22
  import { isProviderEnabled } from "../../config/runtime";
22
23
  import { sendMeshPayload } from "../../host/session-keys";
23
- import { linkSessionsToProjects, workingTreeState } from "../catalog";
24
+ import { inspectRepository, linkSessionsToProjects, workingTreeState } from "../catalog";
24
25
  import { catalogFromEndpoint } from "../catalog/models";
25
26
  import { saveProjectCapabilityRequest } from "../catalog/project/requests";
26
27
  import { computerId } from "../identity/computer";
@@ -36,6 +37,7 @@ import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
36
37
  import { fetchRevision, pushBranch } from "../local-remote/remote";
37
38
  import { projectSessionCapabilityInventory } from "./capabilities";
38
39
  import { createMeshSessionCatalog } from "./catalog/session-catalog";
40
+ import { capturePreparedHandoff, preparedHandoffMetadata } from './prepared-transfer';
39
41
 
40
42
  export type { MeshRuntimeDependencies };
41
43
 
@@ -56,6 +58,14 @@ const defaultDependencies: MeshRuntimeDependencies = {
56
58
  now: Date.now,
57
59
  eventId: randomUUID,
58
60
  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
+ },
67
+ dispatchPreparedTransfer: async (event, session, confirm) =>
68
+ (await import('../../task-network/runtime')).submitNetworkTransfer(event, session, confirm),
59
69
  start: async (provider, prompt, cwd, model) => {
60
70
  if (provider === "claude") return createClaudeSession(prompt, cwd, 240_000, [], model);
61
71
  if (provider === "codex") return createCodexSession(prompt, cwd, 240_000, [], model);
@@ -67,12 +77,13 @@ const defaultDependencies: MeshRuntimeDependencies = {
67
77
  push: pushBranch,
68
78
  fetch: fetchRevision,
69
79
  send: (client, payload, options) => sendMeshPayload(client, payload, "phone", options),
70
- worktree: (repository, taskId, provider, revision) => createHandoffWorktree({
80
+ worktree: (repository, taskId, provider, revision, operationId) => createHandoffWorktree({
71
81
  repository,
72
82
  worktreeRoot: join(configDir(), "worktrees"),
73
83
  taskId,
74
84
  provider,
75
85
  baseSha: revision,
86
+ operationId,
76
87
  }),
77
88
  };
78
89
 
@@ -89,6 +100,10 @@ async function prepareHandoff(
89
100
  if (!session || session.projectId !== request.projectId || session.taskId !== request.taskId) {
90
101
  return false;
91
102
  }
103
+ if (request.prepareOnly && session.state === "working") {
104
+ await blockHandoff(client, request, "Pause or finish the current turn before transferring this Task.");
105
+ return false;
106
+ }
92
107
  const cwd = session.worktree ?? session.cwd;
93
108
  const made = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
94
109
  ? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, request.createdAt)
@@ -100,7 +115,7 @@ async function prepareHandoff(
100
115
  && (other.worktree ?? other.cwd) === cwd)
101
116
  : false;
102
117
  const checkpoint = made && shared ? { ...made, status: "requires_review" as const } : made;
103
- const capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
118
+ let capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
104
119
  const readiness = handoffReadiness({
105
120
  capsule,
106
121
  targetProviderEnabled: request.targetProvider === "grok_bot"
@@ -119,6 +134,19 @@ async function prepareHandoff(
119
134
  return false;
120
135
  }
121
136
  if (!capsule) return false;
137
+ let transferContext: string | undefined;
138
+ let confirmContext: (() => Promise<boolean>) | undefined;
139
+ if (request.prepareOnly) {
140
+ try {
141
+ const captured = await capturePreparedHandoff(deps, session, capsule);
142
+ transferContext = captured.transferContext;
143
+ capsule = captured.capsule;
144
+ confirmContext = captured.confirm;
145
+ } catch (error) {
146
+ await blockHandoff(client, request, error instanceof Error ? error.message : "The complete transfer context is unavailable.");
147
+ return false;
148
+ }
149
+ }
122
150
  const local = request.targetComputer === deps.computer();
123
151
  if (request.push && !local && cwd) {
124
152
  const pushed = deps.push(cwd, capsule.branch);
@@ -138,10 +166,22 @@ async function prepareHandoff(
138
166
  eventType: "HANDOFF_REQUEST",
139
167
  createdAt,
140
168
  expiresAt: createdAt + 60 * 60_000,
141
- payload: { capsule },
169
+ payload: { capsule, transferContext },
142
170
  };
171
+ if (request.prepareOnly && !local && deps.dispatchPreparedTransfer) {
172
+ try {
173
+ if (await deps.dispatchPreparedTransfer(event, session, confirmContext!)) {
174
+ await deps.send(client, preparedHandoffMetadata(event), { ttlMs: 60 * 60_000, wake: true }).catch(() => {});
175
+ return true;
176
+ }
177
+ await blockHandoff(client, request, 'Project computer transfer is not configured for this destination. Connect its Project in GrantTap.');
178
+ } catch (error) {
179
+ await blockHandoff(client, request, error instanceof Error ? error.message : 'The transfer could not be saved.');
180
+ }
181
+ return false;
182
+ }
143
183
  deps.store().acceptEvent(event);
144
- await deps.send(client, event, { ttlMs: 60 * 60_000, wake: true });
184
+ await deps.send(client, request.prepareOnly ? preparedHandoffMetadata(event) : event, { ttlMs: 60 * 60_000, wake: true });
145
185
  if (local) await acceptHandoff(client, event);
146
186
  return true;
147
187
  }
@@ -199,8 +239,20 @@ export function createMeshRuntime(deps: MeshRuntimeDependencies) {
199
239
  return true;
200
240
  }
201
241
  const fresh = store.acceptEvent(payload);
242
+ if (payload.eventType === "HANDOFF_REQUEST" && origin === "relay"
243
+ && (fresh || payload.payload.capsule?.launch === "on_message")) await acceptHandoff(client, payload);
202
244
  if (!fresh) return true;
203
- if (payload.eventType === "HANDOFF_REQUEST" && origin === "relay") await acceptHandoff(client, payload);
245
+ if (payload.eventType === "HANDOFF_ACCEPTED" && payload.payload.receipt?.prepared) {
246
+ const receipt = payload.payload.receipt;
247
+ const request = store.eventsForProject(payload.projectId).find(event =>
248
+ event.eventType === "HANDOFF_REQUEST" && event.sourceSessionId === receipt.sourceSessionId
249
+ && event.payload.capsule?.sourceComputer === deps.computer());
250
+ if (request) (deps.continuations ?? localTransferContinuations)().record({
251
+ sourceSessionId: receipt.sourceSessionId, targetSessionId: receipt.targetSessionId,
252
+ projectId: payload.projectId, taskId: receipt.taskId, capsuleHash: receipt.capsuleHash,
253
+ acceptedAt: receipt.acceptedAt, queuedAt: request.createdAt,
254
+ }, (deps.transfers ?? localTaskTransfers)().nativeSession({ sessionId: receipt.sourceSessionId } as SessionInfo).sessionId);
255
+ }
204
256
  if (payload.eventType === "AGENT_QUESTION") await answerAgentQuestion(client, payload);
205
257
  return true;
206
258
  },
@@ -211,7 +263,12 @@ export function createMeshRuntime(deps: MeshRuntimeDependencies) {
211
263
  };
212
264
  }
213
265
 
214
- const defaultRuntime = createMeshRuntime(defaultDependencies);
266
+ /** Local adapters may replace a scoped transport without rebuilding runtime dependencies. */
267
+ export function createLocalMeshRuntime(overrides: Partial<MeshRuntimeDependencies> = {}) {
268
+ return createMeshRuntime({ ...defaultDependencies, ...overrides });
269
+ }
270
+
271
+ const defaultRuntime = createLocalMeshRuntime();
215
272
 
216
273
  export function meshCatalog(sessions: SessionInfo[]): SessionInfo[] {
217
274
  return defaultRuntime.catalog(sessions);
@@ -0,0 +1,27 @@
1
+ import type { MeshEvent, SessionInfo, TaskCapsule } from '../../../../../../packages/protocol/schema';
2
+ import type { MeshRuntimeDependencies } from '../dependencies';
3
+ import { captureTransferContext } from '../../../task-transfer';
4
+
5
+ /** The private computer transport owns context; phone notifications are observations only. */
6
+ export function preparedHandoffMetadata(event: MeshEvent): MeshEvent {
7
+ const { transferContext: _context, ...payload } = event.payload;
8
+ return { ...event, payload };
9
+ }
10
+
11
+ /** Retains the original archive facts so reconfirmation hashes the same conversation. */
12
+ export async function capturePreparedHandoff(deps: MeshRuntimeDependencies, session: SessionInfo, facts: TaskCapsule) {
13
+ const capture = deps.captureTransferContext ?? captureTransferContext;
14
+ const captured = await capture(session, facts);
15
+ const task = deps.store().task(facts.taskId);
16
+ return { transferContext: captured.context,
17
+ capsule: { ...facts, title: task?.title ?? session.title ?? facts.goal.slice(0, 160),
18
+ launch: 'on_message' as const, contextHash: captured.hash },
19
+ confirm: async () => {
20
+ const current = deps.sessions().find(row => row.sessionId === session.sessionId);
21
+ if (!current || current.state === 'working' || current.model !== session.model || current.effort !== session.effort
22
+ || (current.worktree ?? current.cwd) !== (session.worktree ?? session.cwd)
23
+ || deps.sourceCaptureCurrent && !deps.sourceCaptureCurrent(current, facts)) return false;
24
+ try { return (await capture(current, facts)).hash === captured.hash; }
25
+ catch { return false; }
26
+ } };
27
+ }
@@ -1,4 +1,5 @@
1
1
  import { refreshLocalGraphBindings } from "../graph/binding-sync";
2
+ import { graphAnalysisBindings } from "../graph/checkout-identity";
2
3
  import type { MeshSnapshot } from "../../../../../../packages/protocol/schema";
3
4
  import {
4
5
  projectBackbone, projectRepositoryGraphs, waitForProjectBindingSync,
@@ -16,7 +17,8 @@ export async function enrichMeshSnapshot(
16
17
  ): Promise<MeshSnapshot> {
17
18
  const identityFailures = await refreshLocalGraphBindings(snapshot);
18
19
  const excludedRepositories = new Set(identityFailures.map((report) => report.repositoryId));
19
- const bindings = (snapshot.bindings ?? []).filter((binding) => !excludedRepositories.has(binding.repositoryId));
20
+ const bindings = graphAnalysisBindings((snapshot.bindings ?? [])
21
+ .filter((binding) => !excludedRepositories.has(binding.repositoryId)));
20
22
  await waitForProjectBindingSync(snapshot.projectId);
21
23
  queueProjectKnowledgeSync({
22
24
  projectId: snapshot.projectId,
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, lstatSync, realpathSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
4
5
 
5
6
  export type HandoffWorktree = { path: string; branch: string };
6
7
 
@@ -28,15 +29,19 @@ export function createHandoffWorktree(input: {
28
29
  provider: string;
29
30
  baseSha: string;
30
31
  now?: number;
32
+ operationId?: string;
31
33
  }): HandoffWorktree | undefined {
32
34
  const { repository, worktreeRoot, taskId, provider, baseSha, now = Date.now() } = input;
33
35
  if (!/^[0-9a-f]{7,64}$/i.test(baseSha)) return undefined;
36
+ if (input.operationId !== undefined && (!input.operationId || input.operationId.length > 128)) return undefined;
34
37
  const task = safeSegment(taskId) || "task";
35
38
  const agent = safeSegment(provider) || "agent";
36
- const suffix = Math.trunc(now).toString(36);
39
+ const suffix = input.operationId ? createHash('sha256').update(input.operationId).digest('hex').slice(0, 24)
40
+ : Math.trunc(now).toString(36);
37
41
  const branch = `granttap/${agent}/${task}-${suffix}`;
38
42
  const path = join(worktreeRoot, `${task}-${agent}-${suffix}`);
39
- if (existsSync(path)) return undefined;
43
+ if (existsSync(path)) return input.operationId && reusableWorktree(path, repository, baseSha, branch)
44
+ ? { path, branch } : undefined;
40
45
  mkdirSync(worktreeRoot, { recursive: true, mode: 0o700 });
41
46
  try {
42
47
  execFileSync("git", ["-C", repository, "cat-file", "-e", `${baseSha}^{commit}`], {
@@ -50,3 +55,19 @@ export function createHandoffWorktree(input: {
50
55
  return undefined;
51
56
  }
52
57
  }
58
+
59
+ function reusableWorktree(path: string, repository: string, revision: string, branch: string): boolean {
60
+ const git = (cwd: string, args: string[]) => execFileSync('git', ['-C', cwd, ...args], {
61
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3_000, maxBuffer: 512 * 1_024,
62
+ }).trim();
63
+ try {
64
+ if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory()) return false;
65
+ const root = realpathSync(path);
66
+ return realpathSync(git(path, ['rev-parse', '--show-toplevel'])) === root
67
+ && realpathSync(resolve(path, git(path, ['rev-parse', '--git-common-dir'])))
68
+ === realpathSync(resolve(repository, git(repository, ['rev-parse', '--git-common-dir'])))
69
+ && git(path, ['rev-parse', 'HEAD']) === git(repository, ['rev-parse', `${revision}^{commit}`])
70
+ && git(path, ['branch', '--show-current']) === branch
71
+ && git(path, ['status', '--porcelain=v1', '--untracked-files=all']) === '';
72
+ } catch { return false; }
73
+ }
@@ -1,3 +1,4 @@
1
+ import { localTaskTransfers } from "../../task-transfer";
1
2
  import {
2
3
  ExecutionSessionLink,
3
4
  HandoffReceipt,
@@ -276,6 +277,11 @@ export class MeshStoreBase {
276
277
  const own = this.state.executions.find((item) =>
277
278
  item.computerId === computerId && item.provider === provider && item.sessionId === sessionId);
278
279
  if (own) return own.taskId;
280
+ const transfer = localTaskTransfers().executionForNative(provider, sessionId);
281
+ if (transfer?.capsule.targetComputer === computerId) {
282
+ return this.state.executions.find(item => item.sessionId === transfer.sessionId
283
+ && item.computerId === computerId && item.provider === provider)?.taskId;
284
+ }
279
285
  return this.state.executions.find((item) =>
280
286
  item.provider === provider && item.sessionId === sessionId)?.taskId;
281
287
  }