granttap-mcp 0.8.21 → 0.8.22

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 (34) hide show
  1. package/README.md +23 -1
  2. package/apps/bridge/src/capabilities/README.md +5 -0
  3. package/apps/bridge/src/cortex/integration.ts +10 -7
  4. package/apps/bridge/src/engine/protocol/engine-memory-protocol.ts +3 -0
  5. package/apps/bridge/src/engine/runtime/engine-memory.ts +56 -34
  6. package/apps/bridge/src/mesh/catalog/project/observations.ts +7 -0
  7. package/apps/bridge/src/mesh/context/env.ts +32 -2
  8. package/apps/bridge/src/mesh/context/project.ts +13 -2
  9. package/apps/bridge/src/mesh/knowledge/README.md +14 -0
  10. package/apps/bridge/src/mesh/knowledge/active.ts +64 -0
  11. package/apps/bridge/src/mesh/knowledge/write.ts +45 -0
  12. package/apps/bridge/src/mesh/runtime/index.ts +13 -2
  13. package/apps/bridge/src/mesh/snapshot/scoped-view.ts +5 -1
  14. package/apps/bridge/src/mesh/store/index.ts +10 -1
  15. package/apps/bridge/src/monitor/controller-knowledge/index.ts +51 -0
  16. package/apps/bridge/src/monitor/handlers/inbound.ts +21 -7
  17. package/apps/bridge/src/monitor/support/runtime.ts +1 -1
  18. package/apps/bridge/src/pairing/controller-invites.ts +92 -0
  19. package/apps/mcp/src/connection-center/widget.html +1 -1
  20. package/cursor-plugin/.cursor-plugin/plugin.json +1 -1
  21. package/cursor-plugin/README.md +1 -1
  22. package/cursor-plugin/mcp.json +1 -1
  23. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  24. package/docs/images/iphone-health-code-towers.png +0 -0
  25. package/docs/images/iphone-linked-projects.png +0 -0
  26. package/docs/images/iphone-weavatrix-graph.png +0 -0
  27. package/package.json +2 -2
  28. package/packages/core/relay-client.ts +21 -0
  29. package/packages/protocol/messages/README.md +6 -0
  30. package/packages/protocol/messages/device/controller-pairing.ts +32 -0
  31. package/packages/protocol/messages/mesh/knowledge/index.ts +1 -0
  32. package/packages/protocol/messages/mesh/knowledge/write.ts +27 -0
  33. package/packages/protocol/messages/mesh/snapshot.ts +6 -0
  34. package/packages/protocol/schema.ts +8 -0
package/README.md CHANGED
@@ -80,12 +80,34 @@ separate from their path identity. Missing content evidence remains unknown.
80
80
 
81
81
  <p align="center">
82
82
  <img src="docs/images/iphone-projects-shared.png" width="200" alt="Projects: one owned by this phone, one shared by another">
83
+ <img src="docs/images/iphone-linked-projects.png" width="200" alt="Two linked Projects with separate access scopes">
83
84
  <img src="docs/images/iphone-project-mesh.png" width="200" alt="A Project: Governance, members and computers, Mesh status, repositories, Tasks">
84
85
  <img src="docs/images/iphone-task-route.png" width="200" alt="A Task: executions, Runtime history, and resource claims">
85
86
  <img src="docs/images/iphone-handoff.png" width="200" alt="Task handoff: destination, push, and readiness checks">
86
87
  </p>
87
88
 
88
- <p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with executions, Runtime history, and claims · a handoff with its readiness checks</em></p>
89
+ <p align="center"><em>Shared Projects · linked repository bindings with separate Project access · Project management · Task execution history · handoff readiness</em></p>
90
+
91
+ <p align="center">
92
+ <img src="docs/images/iphone-weavatrix-graph.png" width="200" alt="Full-screen architecture graph with sample evidence">
93
+ <img src="docs/images/iphone-health-code-towers.png" width="200" alt="Full-screen Health code towers with sample evidence">
94
+ </p>
95
+
96
+ <p align="center"><em>These are deterministic simulator fixtures, not reports from a live customer repository.</em></p>
97
+
98
+ An authenticated controller can request a separate, one-time device credential
99
+ for another iPhone or iPad from each Live linked computer. The phone packages
100
+ the resulting links in an encrypted, single-use QR. This adds the new phone to
101
+ the same device network; it does not grant Project membership. Explicit human
102
+ decisions for a bound Project Task can be recorded through Engine Memory, and
103
+ a same-scope correction supersedes an earlier decision while retaining history.
104
+ The computer returns `recorded` only after the Engine confirms storage.
105
+
106
+ The capability catalog distinguishes local observations from installed and
107
+ initialized state. It does not yet transfer and apply complete MCP or skill
108
+ bundles to every host. Usage figures do not enforce spending caps. Project
109
+ Environment secrets injected into an agent process remain readable by that
110
+ process; a use-only credential broker is not part of this runtime yet.
89
111
 
90
112
  For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
91
113
  own session and sees the exact call. GrantTap attributes every `notify` to the
@@ -2,4 +2,9 @@
2
2
 
3
3
  This module discovers provider-configured MCP servers and available skills. Metadata probing is isolated because it may start an MCP transport; local descriptors and skill discovery remain read-only.
4
4
 
5
+ Skill digests cover the bounded bundle, including scripts and references.
6
+ Catalog observations and Project requests do not install a skill or native MCP
7
+ configuration on another host. Availability requires a separate native apply
8
+ and independently observed initialization; that rollout is still unfinished.
9
+
5
10
  License: this module is distributed under the MIT License in the repository-root `LICENSE` file.
@@ -14,6 +14,7 @@ import type { ScopedMeshView } from "../mesh/snapshot/scoped-view";
14
14
  import { loadRuntimeConfig } from "../config/runtime";
15
15
  import { computerId } from "../mesh/identity/computer";
16
16
  import { defaultCortexConfig } from "./config";
17
+ import { activeProjectKnowledge, activeScopedKnowledge, unrecordedKnowledgeEvents } from "../mesh/knowledge/active";
17
18
 
18
19
  type CortexDependencies = {
19
20
  provenance?: typeof projectEngineProvenance;
@@ -109,15 +110,16 @@ export function cortexSnapshotEvidence(
109
110
  `repository.${graph.repositoryId}`, "weavatrix.repository", JSON.stringify(graph),
110
111
  "high", "verified", "graph", graph.revision,
111
112
  ));
112
- const knowledge = (snapshot.knowledge ?? []).filter((record) =>
113
+ const knowledge = activeProjectKnowledge(snapshot.projectId, snapshot.knowledge ?? []).filter((record) =>
113
114
  record.taskId === taskId || record.visibility === "project").slice(0, 16);
114
115
  for (const record of knowledge) evidence.push(item(
115
116
  `knowledge.${record.recordId}`, "weavatrix.memory", JSON.stringify(record),
116
117
  "high", "unverified", "memory", `memory:${record.streamVersion}`,
117
118
  ));
118
- const recorded = new Set(knowledge.map((record) => record.sourceRef));
119
- for (const event of snapshot.events.filter((value) => value.taskId === taskId
120
- && !recorded.has(value.eventId)).slice(-32)) {
119
+ for (const event of unrecordedKnowledgeEvents(
120
+ snapshot.events.filter((value) => value.taskId === taskId),
121
+ snapshot.knowledge ?? [], snapshot.supersededKnowledgeRecordIds,
122
+ ).slice(-32)) {
121
123
  evidence.push(item(
122
124
  `event.${event.eventId}`, "mesh.event", JSON.stringify(event),
123
125
  "normal", "unverified", "memory", `event:${event.createdAt}`,
@@ -145,13 +147,14 @@ export function cortexScopedEvidence(view: ScopedMeshView): EngineContextEvidenc
145
147
  `repository.${graph.repositoryId}`, "weavatrix.repository", JSON.stringify(graph),
146
148
  "high", "verified", "graph", graph.revision,
147
149
  ));
148
- const knowledge = (view.knowledge ?? []).slice(0, 16);
150
+ const knowledge = activeScopedKnowledge(view.project.projectId, view.knowledge ?? [], taskId).slice(0, 16);
149
151
  for (const record of knowledge) evidence.push(item(
150
152
  `knowledge.${record.recordId}`, "weavatrix.memory", JSON.stringify(record),
151
153
  "high", "unverified", "memory", `memory:${record.streamVersion}`,
152
154
  ));
153
- const recorded = new Set(knowledge.map((record) => record.sourceRef));
154
- for (const event of view.events.filter((value) => !recorded.has(value.eventId)).slice(-32)) evidence.push(item(
155
+ for (const event of unrecordedKnowledgeEvents(
156
+ view.events, view.knowledge ?? [], view.supersededKnowledgeRecordIds,
157
+ ).slice(-32)) evidence.push(item(
155
158
  `event.${event.eventId}`, "mesh.event", JSON.stringify(event),
156
159
  "normal", "unverified", "memory", `event:${event.createdAt}`,
157
160
  ));
@@ -12,6 +12,7 @@ export type KnowledgeRecordInput = {
12
12
  visibility: KnowledgeVisibility;
13
13
  repository_id?: string | null;
14
14
  commit_sha?: string | null;
15
+ supersedes_record_id?: string | null;
15
16
  recorded_at: number;
16
17
  };
17
18
 
@@ -28,6 +29,7 @@ export type MemoryEngineOperation =
28
29
  | { operation: "memory.history"; input: {
29
30
  project_id: string; task_id?: string | null; visibility?: KnowledgeVisibility | null;
30
31
  before_version?: number | null; limit?: number | null;
32
+ include_superseded?: boolean;
31
33
  } };
32
34
  export type MemoryEngineResult =
33
35
  | { operation: "memory.recorded"; record_id: string; stream_version: number }
@@ -62,6 +64,7 @@ export function parseMemoryResult(result: Record<string, unknown>, invalid: () =
62
64
  if (entry.repository_id != null) bounded(entry.repository_id, 512, invalid);
63
65
  if (entry.commit_sha != null && (typeof entry.commit_sha !== "string"
64
66
  || !/^[0-9a-f]{7,64}$/i.test(entry.commit_sha))) invalid();
67
+ if (entry.supersedes_record_id != null) bounded(entry.supersedes_record_id, 128, invalid);
65
68
  integer(entry.recorded_at, invalid);
66
69
  integer(entry.stream_version, invalid);
67
70
  }
@@ -7,10 +7,11 @@ import { EngineClient } from "./engine-client";
7
7
  import { engineFeatureEnabled, type EngineClientLike } from "./engine-supervisor";
8
8
 
9
9
  type Options = { env?: NodeJS.ProcessEnv; client?: EngineClientLike };
10
+ type KnowledgeSourceWindow = Pick<MeshSnapshot, "projectId" | "events">;
10
11
  let sharedClient: EngineClient | undefined;
11
12
  const synced = new Set<string>();
12
13
  const pendingSyncs = new Map<string, Promise<void>>();
13
- const nextIndex = new Map<string, number>();
14
+ const queuedSyncs = new Map<string, { snapshot: KnowledgeSourceWindow; options: Options }>();
14
15
 
15
16
  function client(options: Options): EngineClientLike {
16
17
  return options.client ?? (sharedClient ??= new EngineClient({
@@ -34,23 +35,36 @@ export async function projectKnowledge(
34
35
  ): Promise<ProjectKnowledgeRecord[] | undefined> {
35
36
  if (!engineFeatureEnabled(options.env ?? process.env)) return undefined;
36
37
  try {
37
- const result = await client(options).request({
38
- operation: "memory.history",
39
- input: { project_id: projectId, task_id: taskId,
40
- visibility: taskId ? undefined : "project", limit: 32 },
41
- }, { timeoutMs: 250 });
42
- if (result.operation !== "memory.history" || result.page.project_id !== projectId) return undefined;
43
- return result.page.entries.filter((entry) => taskId
44
- ? entry.task_id === taskId || entry.visibility === "project"
45
- : entry.visibility === "project").map((entry) => ({
46
- projectId: entry.project_id, taskId: entry.task_id,
47
- recordId: entry.record_id, category: entry.category,
48
- content: entry.content, source: entry.source,
49
- sourceRef: entry.source_ref, visibility: entry.visibility,
50
- repositoryId: entry.repository_id ?? undefined,
51
- commitSha: entry.commit_sha ?? undefined,
52
- recordedAt: entry.recorded_at, streamVersion: entry.stream_version,
53
- }));
38
+ const records: ProjectKnowledgeRecord[] = [];
39
+ let beforeVersion: number | undefined;
40
+ // The local Mesh cache holds at most 256 records. Read that same bounded
41
+ // window, including corrections, before deciding which entries are active.
42
+ for (let page = 0; page < 4; page += 1) {
43
+ const result = await client(options).request({
44
+ operation: "memory.history",
45
+ input: { project_id: projectId, task_id: taskId,
46
+ visibility: taskId ? undefined : "project", limit: 64,
47
+ include_superseded: true, before_version: beforeVersion },
48
+ }, { timeoutMs: 250 });
49
+ if (result.operation !== "memory.history" || result.page.project_id !== projectId) return undefined;
50
+ records.push(...result.page.entries.filter((entry) => taskId
51
+ ? entry.task_id === taskId || entry.visibility === "project"
52
+ : entry.visibility === "project").map((entry) => ({
53
+ projectId: entry.project_id, taskId: entry.task_id,
54
+ recordId: entry.record_id, category: entry.category,
55
+ content: entry.content, source: entry.source,
56
+ sourceRef: entry.source_ref, visibility: entry.visibility,
57
+ repositoryId: entry.repository_id ?? undefined,
58
+ commitSha: entry.commit_sha ?? undefined,
59
+ supersedesRecordId: entry.supersedes_record_id ?? undefined,
60
+ recordedAt: entry.recorded_at, streamVersion: entry.stream_version,
61
+ })));
62
+ const next = result.page.next_before_version;
63
+ if (!result.page.incomplete || next == null
64
+ || beforeVersion != null && next >= beforeVersion) break;
65
+ beforeVersion = next;
66
+ }
67
+ return records;
54
68
  } catch { return undefined; }
55
69
  }
56
70
 
@@ -86,31 +100,39 @@ export function recordsFromEvent(event: MeshEvent): KnowledgeRecordInput[] {
86
100
  return [];
87
101
  }
88
102
 
89
- /** Backfill the bounded event window once per process, then append only new IDs. */
90
- export async function syncProjectKnowledge(snapshot: MeshSnapshot, options: Options = {}): Promise<void> {
103
+ /** Backfill retained structured events; a failed Engine write stops this pass. */
104
+ export async function syncProjectKnowledge(snapshot: KnowledgeSourceWindow, options: Options = {}): Promise<void> {
91
105
  if (!engineFeatureEnabled(options.env ?? process.env)) return;
92
106
  const candidates = snapshot.events.flatMap(recordsFromEvent)
93
- .filter((entry) => !synced.has(`${entry.project_id}\0${entry.record_id}`));
107
+ .filter((entry) => entry.project_id === snapshot.projectId
108
+ && !synced.has(`${entry.project_id}\0${entry.record_id}`))
109
+ .slice(-512);
94
110
  if (candidates.length === 0) return;
95
- const start = (nextIndex.get(snapshot.projectId) ?? 0) % candidates.length;
96
- const count = Math.min(candidates.length, 16);
97
- nextIndex.set(snapshot.projectId, start + count);
98
- for (let offset = 0; offset < count; offset += 1) {
99
- const entry = candidates[(start + offset) % candidates.length]!;
100
- const key = `${entry.project_id}\0${entry.record_id}`;
101
- if (await recordProjectKnowledge(entry, options)) {
102
- synced.add(key);
103
- if (synced.size > 4_096) synced.delete(synced.values().next().value!);
111
+ for (const [index, entry] of candidates.entries()) {
112
+ if (index > 0 && index % 16 === 0) {
113
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
104
114
  }
115
+ const key = `${entry.project_id}\0${entry.record_id}`;
116
+ if (!await recordProjectKnowledge(entry, options)) break;
117
+ synced.add(key);
118
+ if (synced.size > 4_096) synced.delete(synced.values().next().value!);
105
119
  }
106
120
  }
107
121
 
108
122
  /** Publish never waits for the memory backfill; one bounded writer runs per Project. */
109
- export function queueProjectKnowledgeSync(snapshot: MeshSnapshot): void {
110
- if (pendingSyncs.has(snapshot.projectId)) return;
111
- const pending = syncProjectKnowledge(snapshot);
123
+ export function queueProjectKnowledgeSync(snapshot: KnowledgeSourceWindow, options: Options = {}): void {
124
+ if (pendingSyncs.has(snapshot.projectId)) {
125
+ queuedSyncs.set(snapshot.projectId, { snapshot, options });
126
+ return;
127
+ }
128
+ const pending = syncProjectKnowledge(snapshot, options);
112
129
  pendingSyncs.set(snapshot.projectId, pending);
113
130
  void pending.catch(() => undefined).finally(() => {
114
- if (pendingSyncs.get(snapshot.projectId) === pending) pendingSyncs.delete(snapshot.projectId);
131
+ if (pendingSyncs.get(snapshot.projectId) !== pending) return;
132
+ pendingSyncs.delete(snapshot.projectId);
133
+ const next = queuedSyncs.get(snapshot.projectId);
134
+ if (!next) return;
135
+ queuedSyncs.delete(snapshot.projectId);
136
+ queueProjectKnowledgeSync(next.snapshot, next.options);
115
137
  });
116
138
  }
@@ -55,6 +55,13 @@ function mcpObservation(
55
55
  if (candidates.length === 0) return { state: "not_found" };
56
56
  const digests = new Set(candidates.map((server) => server.configDigest).filter(Boolean));
57
57
  const artifactDigest = digests.size === 1 ? [...digests][0] : undefined;
58
+ if (request.artifactDigest) {
59
+ if (candidates.some((server) => server.configDigest
60
+ && server.configDigest !== request.artifactDigest)) {
61
+ return { state: "version_conflict", artifactDigest };
62
+ }
63
+ if (candidates.some((server) => !server.configDigest)) return { state: "unsupported" };
64
+ }
58
65
  if (!request.artifactDigest) {
59
66
  if (digests.size > 1) return { state: "version_conflict" };
60
67
  if (digests.size === 1 && candidates.some((server) => !server.configDigest)) {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { ProjectEnvironment, ProjectEnvVar } from "../../../../../packages/protocol/schema";
4
4
  import { ProjectEnvironment as EnvironmentSchema } from "../../../../../packages/protocol/schema";
@@ -10,6 +10,7 @@ type StoreFile = { environments: ProjectEnvironment[] };
10
10
  const PROTECTED_KEYS = new Set([
11
11
  "HOME", "PATH", "SHELL", "USER", "LOGNAME", "TMPDIR", "NODE_OPTIONS",
12
12
  ]);
13
+ const MANAGED_HEADER = "# GrantTap Project environment. Secrets stay on the Mesh, not here.";
13
14
 
14
15
  function protectedRuntimeKey(key: string): boolean {
15
16
  return PROTECTED_KEYS.has(key)
@@ -76,6 +77,7 @@ export function rememberEnvironment(
76
77
  ): ProjectEnvironment | undefined {
77
78
  const others = loadAll().filter((item) => item.projectId !== projectId);
78
79
  if (!environment) {
80
+ if (repositoryRoot) removeManagedRepoEnv(repositoryRoot);
79
81
  saveAll(others);
80
82
  return undefined;
81
83
  }
@@ -83,6 +85,8 @@ export function rememberEnvironment(
83
85
  const stored = mergeEnvironment({ ...environment, projectId }, loadEnvironment(projectId));
84
86
  if (stored.shareNonSecretsWithRepo && repositoryRoot) {
85
87
  writeRepoEnv(repositoryRoot, stored.variables);
88
+ } else if (repositoryRoot) {
89
+ removeManagedRepoEnv(repositoryRoot);
86
90
  }
87
91
  saveAll([...others, stored]);
88
92
  return stored;
@@ -120,13 +124,39 @@ export function writeRepoEnv(root: string, variables: ProjectEnvVar[]): void {
120
124
  .map((item) => `${item.key}=${escapeEnv(item.value ?? "")}`);
121
125
  const path = repoEnvPath(root);
122
126
  mkdirSync(dirname(path), { recursive: true });
127
+ if (!lstatSync(dirname(path)).isDirectory()) throw new Error("repository export directory is unsafe");
128
+ const stat = lstatSync(path, { throwIfNoEntry: false });
129
+ if (stat) {
130
+ if (!stat.isFile() || !readFileSync(path, "utf8").startsWith(`${MANAGED_HEADER}\n`)) {
131
+ throw new Error("repository environment file is not managed by GrantTap");
132
+ }
133
+ }
123
134
  writeFileSync(
124
135
  path,
125
- `${["# GrantTap Project environment. Secrets stay on the Mesh, not here.", ...lines].join("\n")}\n`,
136
+ `${[MANAGED_HEADER, ...lines].join("\n")}\n`,
126
137
  { mode: 0o644 },
127
138
  );
128
139
  }
129
140
 
141
+ /** Revoking a share removes a managed export and reports unsafe leftovers. */
142
+ export function removeManagedRepoEnv(root: string): boolean {
143
+ const path = repoEnvPath(root);
144
+ try {
145
+ if (!lstatSync(dirname(path)).isDirectory()) {
146
+ throw new Error("repository environment directory is unsafe");
147
+ }
148
+ if (!lstatSync(path).isFile()
149
+ || !readFileSync(path, "utf8").startsWith(`${MANAGED_HEADER}\n`)) {
150
+ throw new Error("repository environment file is not managed by GrantTap");
151
+ }
152
+ unlinkSync(path);
153
+ return true;
154
+ } catch (error) {
155
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
156
+ throw error;
157
+ }
158
+ }
159
+
130
160
  function escapeEnv(value: string): string {
131
161
  if (!/[\s#"\\]/.test(value)) return value;
132
162
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
@@ -5,6 +5,9 @@
5
5
  import type { ScopedMeshView } from "../snapshot/scoped-view";
6
6
  import { compileMeshContext } from "./packet";
7
7
  import { projectKnowledge } from "../../engine/runtime/engine-memory";
8
+ import {
9
+ activeScopedKnowledge, supersededScopedKnowledgeIds, unrecordedKnowledgeEvents,
10
+ } from "../knowledge/active";
8
11
 
9
12
  export type ProjectContextMode = "compact" | "full" | "legacy";
10
13
 
@@ -139,8 +142,16 @@ export async function renderProjectContext(
139
142
  const local = await projectKnowledge(view.project.projectId, view.execution.taskId);
140
143
  const records = new Map((view.knowledge ?? []).map((item) => [item.recordId, item]));
141
144
  for (const item of local ?? []) records.set(item.recordId, item);
142
- const scoped = { ...view, knowledge: [...records.values()]
143
- .sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 32) };
145
+ const all = [...records.values()];
146
+ const supersededKnowledgeRecordIds = [...new Set([
147
+ ...(view.supersededKnowledgeRecordIds ?? []),
148
+ ...supersededScopedKnowledgeIds(view.project.projectId, all, view.execution.taskId),
149
+ ])];
150
+ const scoped = { ...view,
151
+ knowledge: activeScopedKnowledge(view.project.projectId, all, view.execution.taskId)
152
+ .sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 32),
153
+ supersededKnowledgeRecordIds,
154
+ events: unrecordedKnowledgeEvents(view.events, all, supersededKnowledgeRecordIds) };
144
155
  const contextPacket = await compileMeshContext(scoped);
145
156
  if (mode === "compact") return { ...renderCompactProjectContext(scoped), contextPacket };
146
157
  if (mode === "legacy") {
@@ -0,0 +1,14 @@
1
+ # Mesh knowledge projection
2
+
3
+ `active.ts` filters a Project's bounded phone projection using recorded
4
+ supersession links. The Engine journal remains the durable source and retains
5
+ the complete audit history. A correction only hides an earlier record when both
6
+ records have the same Project, visibility, category, and repository scope.
7
+
8
+ `write.ts` accepts an explicit human decision for an existing Task on a locally
9
+ bound Project, records it through Engine Memory, and returns a `recorded` receipt
10
+ only after the Engine confirms. A correction carries the old record ID; the
11
+ Engine validates its scope. The phone may replay the same ID after a lost reply.
12
+ This is not a migration of old chats into verified knowledge.
13
+
14
+ MIT License.
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { MeshEvent, ProjectKnowledgeRecord } from "../../../../../packages/protocol/schema";
3
+
4
+ /** Supersession is monotone across stale Mesh snapshots; only a same-scope record can hide another. */
5
+ export function activeProjectKnowledge(
6
+ projectId: string, records: ProjectKnowledgeRecord[],
7
+ ): ProjectKnowledgeRecord[] {
8
+ return activeScopedKnowledge(projectId, records);
9
+ }
10
+
11
+ /** Task-private records stay available only to their own Task. */
12
+ export function activeScopedKnowledge(
13
+ projectId: string, records: ProjectKnowledgeRecord[], taskId?: string,
14
+ ): ProjectKnowledgeRecord[] {
15
+ const scoped = records.filter((item) => item.projectId === projectId
16
+ && (item.visibility === "project" || item.visibility === "task" && item.taskId === taskId));
17
+ const byId = new Map(scoped.map((item) => [item.recordId, item]));
18
+ const superseded = new Set(scoped.flatMap((item) => {
19
+ const previous = item.supersedesRecordId && byId.get(item.supersedesRecordId);
20
+ return previous && previous.category === item.category
21
+ && previous.visibility === item.visibility
22
+ && previous.repositoryId === item.repositoryId
23
+ && (item.visibility !== "task" || previous.taskId === item.taskId)
24
+ ? [previous.recordId] : [];
25
+ }));
26
+ return scoped.filter((item) => !superseded.has(item.recordId));
27
+ }
28
+
29
+ /** Keep correction tombstones even when the old record is outside the phone's bounded page. */
30
+ export function supersededProjectKnowledgeIds(
31
+ projectId: string, records: ProjectKnowledgeRecord[],
32
+ ): string[] {
33
+ return supersededScopedKnowledgeIds(projectId, records);
34
+ }
35
+
36
+ export function supersededScopedKnowledgeIds(
37
+ projectId: string, records: ProjectKnowledgeRecord[], taskId?: string,
38
+ ): string[] {
39
+ return [...new Set(records.filter((item) => item.projectId === projectId
40
+ && (item.visibility === "project" || item.visibility === "task" && item.taskId === taskId))
41
+ .sort((left, right) => right.recordedAt - left.recordedAt)
42
+ .flatMap((item) => item.supersedesRecordId ? [item.supersedesRecordId] : [])
43
+ .slice(0, 128))].sort();
44
+ }
45
+
46
+ /** Keep unrelated event context, but remove decisions already recorded or corrected. */
47
+ export function unrecordedKnowledgeEvents(
48
+ events: MeshEvent[], records: ProjectKnowledgeRecord[], supersededIds: string[] = [],
49
+ ): MeshEvent[] {
50
+ const covered = new Set([...records.map((item) => item.recordId), ...supersededIds]);
51
+ return events.flatMap((event) => {
52
+ const capsule = event.eventType === "HANDOFF_REQUEST" ? event.payload.capsule : undefined;
53
+ if (capsule) {
54
+ const importantDecisions = capsule.importantDecisions.filter((_, index) => {
55
+ const id = `capsule-${createHash("sha256")
56
+ .update(JSON.stringify([event.eventId, index])).digest("hex")}`;
57
+ return !covered.has(id);
58
+ });
59
+ return [{ ...event, payload: { ...event.payload,
60
+ capsule: { ...capsule, importantDecisions } } }];
61
+ }
62
+ return covered.has(event.eventId) ? [] : [event];
63
+ });
64
+ }
@@ -0,0 +1,45 @@
1
+ import type { KnowledgeWrite, KnowledgeWriteResult } from "../../../../../packages/protocol/schema";
2
+ import type { KnowledgeRecordInput } from "../../engine/protocol/engine-memory-protocol";
3
+
4
+ type Scope = {
5
+ tasks: Array<{ taskId: string }>;
6
+ bindings: Array<{ endpointId: string }>;
7
+ };
8
+
9
+ type Dependencies = {
10
+ now: () => number;
11
+ endpointId: string;
12
+ snapshot: (projectId: string) => Scope | undefined;
13
+ record: (input: KnowledgeRecordInput) => Promise<boolean>;
14
+ send: (result: KnowledgeWriteResult) => Promise<void>;
15
+ };
16
+
17
+ /** A person's confirmed statement enters Memory only through a bound Project/Task. */
18
+ export async function handleKnowledgeWrite(
19
+ request: KnowledgeWrite, deps: Dependencies,
20
+ ): Promise<boolean> {
21
+ const now = deps.now();
22
+ const scope = deps.snapshot(request.projectId);
23
+ let reason: string | undefined;
24
+ if (!scope || !scope.bindings.some((item) => item.endpointId === deps.endpointId)
25
+ || !scope.tasks.some((item) => item.taskId === request.taskId)) {
26
+ reason = "Project, Task, or local computer binding is unavailable.";
27
+ } else if (Math.abs(now - request.createdAt) > 5 * 60_000) {
28
+ reason = "Decision request expired. Try again.";
29
+ } else {
30
+ const input: KnowledgeRecordInput = {
31
+ project_id: request.projectId, task_id: request.taskId,
32
+ record_id: request.recordId, category: "decision", content: request.content,
33
+ source: "user_decision", source_ref: `phone:${request.recordId}`,
34
+ visibility: "project", recorded_at: request.createdAt,
35
+ ...(request.repositoryId ? { repository_id: request.repositoryId } : {}),
36
+ ...(request.supersedesRecordId
37
+ ? { supersedes_record_id: request.supersedesRecordId } : {}),
38
+ };
39
+ if (!await deps.record(input)) reason = "Memory did not confirm the decision.";
40
+ }
41
+ await deps.send({ type: "knowledge.write.result", projectId: request.projectId,
42
+ taskId: request.taskId, recordId: request.recordId,
43
+ status: reason ? "rejected" : "recorded", ...(reason ? { reason } : {}), createdAt: now });
44
+ return true;
45
+ }
@@ -38,6 +38,7 @@ import { handoffReadiness } from "../tasks/readiness";
38
38
  import { createHandoffFlow } from "./handoff";
39
39
  import type { MeshRuntimeDependencies } from "./dependencies";
40
40
  import { localMeshStore } from "../local-remote/local";
41
+ import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
41
42
  import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
42
43
  import { fetchRevision, pushBranch } from "../local-remote/remote";
43
44
  import { projectSessionCapabilityInventory, projectExecutionCapabilitySessions } from "./capabilities";
@@ -249,7 +250,10 @@ export function requestProjectCapability(input: ProjectCapabilityRequestSet): bo
249
250
  export async function meshSnapshotsWithEngine(): Promise<MeshSnapshot[]> {
250
251
  return Promise.all(meshSnapshots().map(async (snapshot) => {
251
252
  await waitForProjectBindingSync(snapshot.projectId);
252
- queueProjectKnowledgeSync(snapshot);
253
+ queueProjectKnowledgeSync({
254
+ projectId: snapshot.projectId,
255
+ events: localMeshStore().historyEventsForProject(snapshot.projectId),
256
+ });
253
257
  const [backbone, repositoryGraphs, knowledge] = await Promise.all([
254
258
  projectBackbone(snapshot.projectId),
255
259
  projectRepositoryGraphs(snapshot.projectId, snapshot.bindings ?? [], {
@@ -262,8 +266,15 @@ export async function meshSnapshotsWithEngine(): Promise<MeshSnapshot[]> {
262
266
  if (sharedKnowledge) localMeshStore().cacheKnowledge(snapshot.projectId, sharedKnowledge);
263
267
  const memoryRows = new Map((snapshot.knowledge ?? []).map((item) => [item.recordId, item]));
264
268
  for (const item of sharedKnowledge ?? []) memoryRows.set(item.recordId, item);
269
+ const allMemory = [...memoryRows.values()];
270
+ const correctedIds = [...new Set([
271
+ ...(snapshot.supersededKnowledgeRecordIds ?? []),
272
+ ...supersededProjectKnowledgeIds(snapshot.projectId, allMemory),
273
+ ])].sort().slice(-128);
265
274
  const enriched = { ...snapshot, backbone, repositoryGraphs,
266
- knowledge: [...memoryRows.values()].sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 16) };
275
+ knowledge: activeProjectKnowledge(snapshot.projectId, allMemory)
276
+ .sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 16),
277
+ ...(correctedIds.length > 0 ? { supersededKnowledgeRecordIds: correctedIds } : {}) };
267
278
  const cortex = await projectCortexIntegration(enriched);
268
279
  return {
269
280
  ...enriched,
@@ -10,6 +10,7 @@ import type { ExecutionCapability } from "../catalog/capability";
10
10
  import { liveExecutionScope } from "../catalog/capability";
11
11
  import { otherSide, type OtherSideRow } from "../handoff/other-side";
12
12
  import { scopedOverlapKind } from "../store/support";
13
+ import { unrecordedKnowledgeEvents } from "../knowledge/active";
13
14
 
14
15
  const MAX_SCOPED_EVENTS = 32;
15
16
  const MAX_NEIGHBOURS = 32;
@@ -44,6 +45,7 @@ export type ScopedMeshView = {
44
45
  environment?: MeshSnapshot["environment"];
45
46
  cortex?: NonNullable<MeshSnapshot["cortex"]>;
46
47
  knowledge?: NonNullable<MeshSnapshot["knowledge"]>;
48
+ supersededKnowledgeRecordIds?: NonNullable<MeshSnapshot["supersededKnowledgeRecordIds"]>;
47
49
  /** Other Tasks working right now on the far side of this Task's repository. */
48
50
  otherSide: OtherSideRow[];
49
51
  dependencies: MeshSnapshot["dependencies"];
@@ -121,9 +123,11 @@ export function scopedMeshView(
121
123
  cortex: snapshot.cortex ?? [],
122
124
  knowledge: snapshot.knowledge?.filter((item) => item.taskId === capability.taskId
123
125
  || item.visibility === "project"),
126
+ supersededKnowledgeRecordIds: snapshot.supersededKnowledgeRecordIds,
124
127
  otherSide: otherSide(snapshot, capability.taskId),
125
128
  dependencies: snapshot.dependencies,
126
- events: scopedEvents(snapshot, capability),
129
+ events: unrecordedKnowledgeEvents(scopedEvents(snapshot, capability),
130
+ snapshot.knowledge ?? [], snapshot.supersededKnowledgeRecordIds),
127
131
  allowedEventTypes: capability.allowedEventTypes,
128
132
  };
129
133
  }
@@ -31,6 +31,7 @@ import { loadExecutionPolicy } from "../runtime/execution-policy";
31
31
  import { loadEnvironment, redactEnvironment } from "../context/env";
32
32
  import { loadRestrictions } from "../restrictions";
33
33
  import { MeshStoreBase } from "./base";
34
+ import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
34
35
  import {
35
36
  projectCapabilityRequests,
36
37
  } from "../catalog/project/requests";
@@ -268,6 +269,12 @@ export class MeshStore extends MeshStoreBase {
268
269
  return this.state.events.filter((event) => event.projectId === projectId).slice(-128);
269
270
  }
270
271
 
272
+ /** Retained, structured evidence for a one-time Memory backfill. */
273
+ historyEventsForProject(projectId: string): MeshEventValue[] {
274
+ this.sync();
275
+ return this.state.events.filter((event) => event.projectId === projectId);
276
+ }
277
+
271
278
  projectIds(): string[] {
272
279
  this.sync();
273
280
  return this.state.projects.map((project) => project.projectId);
@@ -331,6 +338,7 @@ export class MeshStore extends MeshStoreBase {
331
338
  const execution = loadExecutionPolicy(projectId);
332
339
  const restrictions = loadRestrictions(projectId);
333
340
  const environment = redactEnvironment(loadEnvironment(projectId));
341
+ const correctedIds = supersededProjectKnowledgeIds(projectId, this.state.knowledge);
334
342
  return MeshSnapshot.parse({
335
343
  type: "mesh.snapshot",
336
344
  sessionId: projectId,
@@ -360,8 +368,9 @@ export class MeshStore extends MeshStoreBase {
360
368
  claims: this.activeClaims().filter((item) => item.projectId === projectId && taskIds.has(item.taskId)).slice(-128),
361
369
  dependencies: this.state.dependencies.filter((item) => taskIds.has(item.taskId)).slice(-128),
362
370
  events: this.eventsForProject(projectId).filter((event) => taskIds.has(event.taskId)),
363
- knowledge: this.state.knowledge.filter((item) => item.projectId === projectId)
371
+ knowledge: activeProjectKnowledge(projectId, this.state.knowledge)
364
372
  .slice(-16).reverse(),
373
+ ...(correctedIds.length > 0 ? { supersededKnowledgeRecordIds: correctedIds } : {}),
365
374
  generatedAt: this.now(),
366
375
  });
367
376
  }
@@ -0,0 +1,51 @@
1
+ import type { RelayClient } from "../../../../../packages/core/relay-client";
2
+ import type { ControllerPairRequest, KnowledgeWrite } from "../../../../../packages/protocol/schema";
3
+ import { loadRuntimeConfig } from "../../config";
4
+ import { loadConfig, machineConfigPath } from "../../config";
5
+ import { localMeshStore } from "../../mesh/local-remote/local";
6
+ import { createOneTimePairing, parkedPairingHalves } from "../../pairing";
7
+ import { issueControllerOffer } from "../../pairing/controller-invites";
8
+ import { handleKnowledgeWrite } from "../../mesh/knowledge/write";
9
+ import { recordProjectKnowledge } from "../../engine/runtime/engine-memory";
10
+ import { computerId } from "../../mesh/identity/computer";
11
+
12
+ /** The requester's device key scopes the new controller code, not Project access. */
13
+ export async function handleControllerPairRequest(
14
+ client: RelayClient, request: ControllerPairRequest, peerPublicKey?: string,
15
+ ): Promise<boolean> {
16
+ if (!peerPublicKey || !parkedPairingHalves()) return false;
17
+ const machine = loadConfig(machineConfigPath());
18
+ if (machine.room !== client.room) return false;
19
+ return issueControllerOffer(client, peerPublicKey, request, async () => {
20
+ const pairing = await createOneTimePairing(machine.relayUrl, {
21
+ installHooks: false, addController: true,
22
+ });
23
+ return { room: pairing.machineCfg.room, peerPublicKey: pairing.phoneCfg.myPublicKey,
24
+ uri: pairing.qrPayload };
25
+ });
26
+ }
27
+
28
+ /** A phone gets a recorded receipt only after the local Engine commits Memory. */
29
+ export async function handleInboundKnowledgeWrite(
30
+ client: RelayClient, request: KnowledgeWrite, peerPublicKey: string | undefined,
31
+ publish: () => Promise<void>,
32
+ ): Promise<boolean> {
33
+ if (!peerPublicKey) return false;
34
+ const endpointId = computerId();
35
+ let recorded = false;
36
+ const handled = await handleKnowledgeWrite(request, {
37
+ now: Date.now, endpointId,
38
+ snapshot: (projectId) => {
39
+ if (!loadRuntimeConfig().meshEnabled) return undefined;
40
+ const snapshot = localMeshStore().snapshot(projectId, endpointId);
41
+ return snapshot && { tasks: snapshot.tasks, bindings: snapshot.bindings ?? [] };
42
+ },
43
+ record: async (entry) => {
44
+ recorded = await recordProjectKnowledge(entry);
45
+ return recorded;
46
+ },
47
+ send: (result) => client.sendToPeer(result, peerPublicKey),
48
+ });
49
+ if (recorded) void publish().catch(() => {});
50
+ return handled;
51
+ }
@@ -34,6 +34,7 @@ import { handleUserMessage } from "./user-message";
34
34
  import { publishSessionEvents } from "./catalog";
35
35
  import { publishHistoryPage } from "../history/pages";
36
36
  import { requestProjectGraphRefresh } from "../graph-refresh";
37
+ import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
37
38
 
38
39
  export async function handleMonitorMessage(
39
40
  client: RelayClient,
@@ -42,6 +43,7 @@ export async function handleMonitorMessage(
42
43
  leadership: { acquire: () => boolean };
43
44
  subscriptions: Set<string>;
44
45
  publish: (forceHistory?: boolean) => Promise<void>;
46
+ peerPublicKey?: string;
45
47
  },
46
48
  ): Promise<boolean> {
47
49
  const { leadership, subscriptions, publish } = input;
@@ -52,6 +54,12 @@ export async function handleMonitorMessage(
52
54
  // Codex may start one MCP server per open task. Exactly one instance owns
53
55
  // phone routing, so a single phone message can never create duplicate tasks.
54
56
  if (!leadership.acquire()) return false;
57
+ if (payload.type === "controller.pair.request") {
58
+ return handleControllerPairRequest(client, payload, input.peerPublicKey);
59
+ }
60
+ if (payload.type === "knowledge.write") {
61
+ return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
62
+ }
55
63
  if (payload.type === "user.message") {
56
64
  return handleInboundUserMessage(client, payload, publish);
57
65
  } else if (payload.type === "user.attachment") {
@@ -121,17 +129,23 @@ export async function handleMonitorMessage(
121
129
  if (prepared) void publish().catch(() => {});
122
130
  return prepared;
123
131
  } else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
124
- // The person's own authority, not an owner's event: written down, then
125
- // applied, and answered either way, so a refusal is seen where it was asked.
126
- const outcome = releaseClaimByPerson(localMeshStore(), payload);
127
- await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
128
- .catch(() => {});
129
- if (outcome.released) void publish().catch(() => {});
130
- return true;
132
+ return handleInboundClaimRelease(client, payload, publish);
131
133
  }
132
134
  return false;
133
135
  }
134
136
 
137
+ async function handleInboundClaimRelease(
138
+ client: RelayClient, payload: Extract<Payload, { type: "mesh.claim.release" }>,
139
+ publish: (forceHistory?: boolean) => Promise<void>,
140
+ ): Promise<boolean> {
141
+ // The person's authority is recorded and answered even when release fails.
142
+ const outcome = releaseClaimByPerson(localMeshStore(), payload);
143
+ await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
144
+ .catch(() => {});
145
+ if (outcome.released) void publish().catch(() => {});
146
+ return true;
147
+ }
148
+
135
149
  function handleInboundRefresh(
136
150
  client: RelayClient, payload: Extract<Payload, { type: "sessions.refresh" }>,
137
151
  publish: (forceHistory?: boolean) => Promise<void>,
@@ -273,7 +273,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
273
273
  if (payload.type === "hello" && payload.role === "phone") {
274
274
  confirmPendingController(client.room, peerPublicKey);
275
275
  }
276
- return handleMonitorMessage(client, payload, { leadership, subscriptions, publish });
276
+ return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
277
277
  });
278
278
 
279
279
  const stopCatalog = startPublishLoop({
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ControllerPairOffer, ControllerPairRequest } from "../../../../packages/protocol/schema";
4
+ import { configDir } from "../config/runtime/paths";
5
+ import { writePrivateFile } from "../config/access/write-private";
6
+
7
+ type Record = { requestId: string; requesterKey: string; offer: ControllerPairOffer };
8
+ type Transport = {
9
+ room: string;
10
+ addControllerPeer: (key: string) => void;
11
+ sendToPeer: (offer: ControllerPairOffer, key: string) => Promise<void>;
12
+ };
13
+ type Mint = () => Promise<{ room: string; peerPublicKey: string; uri: string }>;
14
+
15
+ const pending = new Map<string, Promise<boolean>>();
16
+ let issuance = Promise.resolve();
17
+
18
+ function journalPath(): string { return join(configDir(), "controller-pair-offers.json"); }
19
+
20
+ function load(now: number): Record[] | null {
21
+ try {
22
+ const parsed: unknown = JSON.parse(readFileSync(journalPath(), "utf8"));
23
+ if (!Array.isArray(parsed) || parsed.length > 16) return null;
24
+ const valid = (value: unknown): value is Record => {
25
+ if (!value || typeof value !== "object") return false;
26
+ const record = value as Partial<Record>;
27
+ return typeof record.requestId === "string" && typeof record.requesterKey === "string"
28
+ && typeof record.offer?.expiresAt === "number" && typeof record.offer.uri === "string"
29
+ && record.offer.status === "ready";
30
+ };
31
+ if (!parsed.every(valid)) return null;
32
+ return parsed.filter((record: Record) => (record.offer.expiresAt ?? 0) > now);
33
+ } catch { return existsSync(journalPath()) ? null : []; }
34
+ }
35
+
36
+ function persist(records: Record[]): void {
37
+ writePrivateFile(journalPath(), `${JSON.stringify(records)}\n`);
38
+ }
39
+
40
+ function rejected(requestId: string, room: string, reason: string, now: number): ControllerPairOffer {
41
+ return { type: "controller.pair.offer", requestId, room, status: "rejected", reason,
42
+ createdAt: now };
43
+ }
44
+
45
+ async function issue(
46
+ client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
47
+ now: number,
48
+ ): Promise<boolean> {
49
+ const records = load(now);
50
+ let offer: ControllerPairOffer;
51
+ if (!records) {
52
+ offer = rejected(request.requestId, client.room, "Pairing journal needs repair on the computer.", now);
53
+ } else {
54
+ const prior = records.find((item) => item.requestId === request.requestId);
55
+ if (prior && prior.requesterKey !== requesterKey) {
56
+ offer = rejected(request.requestId, client.room, "This request belongs to another controller.", now);
57
+ } else if (prior) {
58
+ offer = prior.offer;
59
+ } else if (Math.abs(now - request.createdAt) > 5 * 60_000) {
60
+ offer = rejected(request.requestId, client.room, "Request expired. Try again.", now);
61
+ } else {
62
+ try {
63
+ const created = await mint();
64
+ if (created.room !== client.room) throw new Error("room changed");
65
+ client.addControllerPeer(created.peerPublicKey);
66
+ offer = { type: "controller.pair.offer", requestId: request.requestId,
67
+ room: client.room, status: "ready", uri: created.uri,
68
+ expiresAt: now + 15 * 60_000, createdAt: now };
69
+ persist([...records, { requestId: request.requestId, requesterKey, offer }].slice(-16));
70
+ } catch {
71
+ offer = rejected(request.requestId, client.room, "Could not create a controller code.", now);
72
+ }
73
+ }
74
+ }
75
+ await client.sendToPeer(offer, requesterKey);
76
+ return true;
77
+ }
78
+
79
+ /** Durable, scoped offer: a relay retry cannot mint a second controller key. */
80
+ export function issueControllerOffer(
81
+ client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
82
+ now = Date.now(),
83
+ ): Promise<boolean> {
84
+ const id = `${client.room}:${request.requestId}`;
85
+ const existing = pending.get(id);
86
+ if (existing) return existing;
87
+ const work = issuance.then(() => issue(client, requesterKey, request, mint, now));
88
+ issuance = work.then(() => undefined, () => undefined);
89
+ pending.set(id, work);
90
+ void work.finally(() => { pending.delete(id); }).catch(() => undefined);
91
+ return work;
92
+ }
@@ -38,7 +38,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
38
38
  <h3>Provider readiness</h3><ul id="providers"></ul>
39
39
  <p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
40
40
  Provider sign-in is separate.</p>
41
- <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.21</code> and <code>granttap setup</code> locally.
41
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.22</code> and <code>granttap setup</code> locally.
42
42
  A saved pairing alone does not prove your phone or the background helper is online.</p>
43
43
  <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
44
44
  <a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.14",
4
+ "version": "0.1.15",
5
5
  "description": "The agent connects this computer in chat. Authorize in plugin settings opens the device window. Scan the QR in GrantTap — that scan authorizes. Device list and Add another stay there and in the GrantTap app.",
6
6
  "author": {
7
7
  "name": "GrantTap",
@@ -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.21
13
+ npm install -g granttap-mcp@0.8.22
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -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.21\";\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"
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.22\";\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.21";
10
+ const PACKAGE = "granttap-mcp@0.8.22";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "setup": "node bin/granttap-mcp.mjs setup",
26
26
  "status": "node bin/granttap-mcp.mjs status",
27
27
  "test": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 node scripts/quality/run-tests.mjs",
28
- "test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines 95 --statements 95 --functions 95 --branches 85 node scripts/quality/run-tests.mjs",
28
+ "test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines 90 --statements 90 --functions 90 --branches 85 node scripts/quality/run-tests.mjs",
29
29
  "inventory": "node scripts/quality/source-inventory.mjs",
30
30
  "structure": "node scripts/quality/structure.mjs",
31
31
  "package:allowlist": "node scripts/quality/package-allowlist.mjs",
@@ -238,6 +238,27 @@ export class RelayClient {
238
238
  }
239
239
  }
240
240
 
241
+ /** Add a newly issued controller without waiting for the monitor to restart. */
242
+ addControllerPeer(peerPublicKey: string): void {
243
+ if (this.cfg.role !== "machine" || !this.peerIsAllowed(peerPublicKey)) {
244
+ throw new Error("controller peer is not authorized");
245
+ }
246
+ const peers = this.cfg.extraPeerPublicKeys ?? [];
247
+ if (peerPublicKey !== this.cfg.peerPublicKey && !peers.includes(peerPublicKey)) {
248
+ if (peers.length >= 16) throw new Error("controller device limit reached");
249
+ this.cfg.extraPeerPublicKeys = [...peers, peerPublicKey];
250
+ }
251
+ }
252
+
253
+ /** Keep a controller invitation visible only to the requesting phone key. */
254
+ async sendToPeer(payload: Payload, peerPublicKey: string, options: SendOptions = {}): Promise<void> {
255
+ const ws = this.ws;
256
+ if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
257
+ if (!peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys).includes(peerPublicKey)
258
+ || !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
259
+ ws.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), options));
260
+ }
261
+
241
262
  setSessionKey(sessionId: string, key: string): void {
242
263
  this.sessionKeys.set(sessionId, key);
243
264
  }
@@ -20,6 +20,12 @@ sealed with the same independent Project key as Mesh snapshots. Coverage keeps
20
20
  `enforced`, `observed`, `unsupported`, and `unknown` distinct so clients never
21
21
  claim a provider can block an action when it cannot.
22
22
 
23
+ `device/controller-pairing.ts` lets an authenticated controller ask its linked
24
+ computer for another one-time controller credential. The reply is sealed only
25
+ to the requester. It is device-network pairing, not Project membership.
26
+ `mesh/knowledge/write.ts` carries a Task-bound human decision or correction and a
27
+ separate Engine-confirmed result.
28
+
23
29
  Tests are discoverable in `tests/core.test.ts` and the feature-level bridge and MCP suites.
24
30
 
25
31
  License: this module is distributed under the MIT License in the repository-root `LICENSE` file.
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+
3
+ const RequestId = z.string().uuid();
4
+
5
+ /** A trusted phone asks its paired computer to mint a separate controller key. */
6
+ export const ControllerPairRequest = z.object({
7
+ type: z.literal("controller.pair.request"),
8
+ requestId: RequestId,
9
+ createdAt: z.number().nonnegative(),
10
+ }).strict();
11
+ export type ControllerPairRequest = z.infer<typeof ControllerPairRequest>;
12
+
13
+ /** Sent only through the authenticated device box, never a Project channel. */
14
+ export const ControllerPairOffer = z.object({
15
+ type: z.literal("controller.pair.offer"),
16
+ requestId: RequestId,
17
+ room: z.string().regex(/^[a-f0-9]{16,64}$/),
18
+ status: z.enum(["ready", "rejected"]),
19
+ uri: z.string().max(1024).optional(),
20
+ expiresAt: z.number().nonnegative().optional(),
21
+ reason: z.string().max(200).optional(),
22
+ createdAt: z.number().nonnegative(),
23
+ }).strict().superRefine((offer, ctx) => {
24
+ if (offer.status === "ready" && (!offer.uri?.startsWith("granttap://pair-v2?")
25
+ || !offer.expiresAt || offer.expiresAt <= offer.createdAt)) {
26
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ready offer needs a valid URI and expiry" });
27
+ }
28
+ if (offer.status === "rejected" && offer.uri) {
29
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "rejected offer cannot carry a URI" });
30
+ }
31
+ });
32
+ export type ControllerPairOffer = z.infer<typeof ControllerPairOffer>;
@@ -13,6 +13,7 @@ export const ProjectKnowledgeRecord = z.object({
13
13
  visibility: z.enum(["task", "project"]),
14
14
  repositoryId: z.string().trim().min(1).max(512).optional(),
15
15
  commitSha: z.string().regex(/^[a-f0-9]{7,64}$/i).optional(),
16
+ supersedesRecordId: Identifier.optional(),
16
17
  recordedAt: z.number().int().nonnegative(),
17
18
  streamVersion: z.number().int().nonnegative(),
18
19
  }).strict();
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { Identifier } from "../endpoint";
3
+
4
+ /** A person's explicit statement, never inferred from a chat title. */
5
+ export const KnowledgeWrite = z.object({
6
+ type: z.literal("knowledge.write"),
7
+ projectId: Identifier,
8
+ taskId: Identifier,
9
+ recordId: Identifier,
10
+ content: z.string().trim().min(1).max(4_096),
11
+ repositoryId: z.string().trim().min(1).max(512).optional(),
12
+ supersedesRecordId: Identifier.optional(),
13
+ createdAt: z.number().int().nonnegative(),
14
+ }).strict().refine((request) => request.recordId !== request.supersedesRecordId,
15
+ "a decision cannot supersede itself");
16
+ export type KnowledgeWrite = z.infer<typeof KnowledgeWrite>;
17
+
18
+ export const KnowledgeWriteResult = z.object({
19
+ type: z.literal("knowledge.write.result"),
20
+ projectId: Identifier,
21
+ taskId: Identifier,
22
+ recordId: Identifier,
23
+ status: z.enum(["recorded", "rejected"]),
24
+ reason: z.string().max(200).optional(),
25
+ createdAt: z.number().int().nonnegative(),
26
+ }).strict();
27
+ export type KnowledgeWriteResult = z.infer<typeof KnowledgeWriteResult>;
@@ -239,6 +239,7 @@ export const MeshSnapshot = z.object({
239
239
  repositoryGraphs: z.array(ProjectRepositoryGraph).max(64).optional(),
240
240
  cortex: z.array(ProjectCortexIntegration).max(32).optional(),
241
241
  knowledge: z.array(ProjectKnowledgeRecord).max(32).optional(),
242
+ supersededKnowledgeRecordIds: z.array(Identifier).max(128).optional(),
242
243
  tasks: z.array(MeshTask).max(64),
243
244
  executions: z.array(ExecutionSessionLink).max(128),
244
245
  claims: z.array(ResourceClaim).max(128),
@@ -295,6 +296,11 @@ export const MeshSnapshot = z.object({
295
296
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["repositoryGraphs"], message: "invalid repository graph scope" });
296
297
  }
297
298
  const knowledgeIds = new Set(value.knowledge?.map((item) => item.recordId));
299
+ const supersededKnowledgeIds = new Set(value.supersededKnowledgeRecordIds);
300
+ if (supersededKnowledgeIds.size !== (value.supersededKnowledgeRecordIds?.length ?? 0)) {
301
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["supersededKnowledgeRecordIds"],
302
+ message: "duplicate superseded knowledge identity" });
303
+ }
298
304
  if ((value.knowledge?.length ?? 0) !== knowledgeIds.size
299
305
  || value.knowledge?.some((item) => item.projectId !== value.projectId
300
306
  || item.visibility !== "project")) {
@@ -31,6 +31,8 @@ import {
31
31
  } from "./messages/mesh";
32
32
  import { MachineLoad } from "./messages/machine";
33
33
  import { PairingJoin } from "./messages/pairing-join";
34
+ import { ControllerPairOffer, ControllerPairRequest } from "./messages/device/controller-pairing";
35
+ import { KnowledgeWrite, KnowledgeWriteResult } from "./messages/mesh/knowledge/write";
34
36
  import { MeshInvocationPage, MeshInvocationQuery } from "./messages/mesh/invocations";
35
37
  import {
36
38
  ProjectPolicyAck,
@@ -63,6 +65,8 @@ export * from "./messages/capabilities";
63
65
  export * from "./messages/interaction";
64
66
  export * from "./messages/machine";
65
67
  export * from "./messages/pairing-join";
68
+ export * from "./messages/device/controller-pairing";
69
+ export * from "./messages/mesh/knowledge/write";
66
70
  export * from "./messages/mesh";
67
71
  export * from "./messages/mesh/invocations";
68
72
  export * from "./messages/primitives";
@@ -147,6 +151,10 @@ export const Payload = z.union([
147
151
  MachineHeartbeat,
148
152
  MachineLoad,
149
153
  PairingJoin,
154
+ ControllerPairRequest,
155
+ ControllerPairOffer,
156
+ KnowledgeWrite,
157
+ KnowledgeWriteResult,
150
158
  ]).superRefine((payload, ctx) => {
151
159
  if (payload.type !== "user.message") return;
152
160
  const encodedCharacters = payload.attachments?.reduce(