granttap-mcp 0.8.47 → 0.8.48

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.
@@ -14,7 +14,8 @@ Mesh snapshot enrichment queues Weavatrix repository analysis through
14
14
  `repository-graph-jobs.ts`. One analysis runs at a time, repeated snapshot
15
15
  ticks reuse a fresh bounded report, and failed analyses back off. An absent
16
16
  report leaves the graph unavailable and Cortex degraded; it does not block
17
- policy IPC or turn a repository label into an architecture result.
17
+ policy IPC: repository analysis owns a separate bounded-lifetime socket, while
18
+ policy and Backbone requests keep their control connection. It does not turn a repository label into an architecture result.
18
19
  Claude, Codex, and Cursor hooks can evaluate the engine's local Project
19
20
  Governance policy only when both `GRANTTAP_ENGINE_ENABLED` and
20
21
  `GRANTTAP_PROJECT_POLICY_ENABLED` are enabled. Project `DENY` and `ASK` precede
@@ -182,7 +182,7 @@ export async function projectRepositoryGraphs(
182
182
  priority?: number } = {},
183
183
  ): Promise<ProjectRepositoryGraph[]> {
184
184
  if (!engineFeatureEnabled(options.env ?? process.env)) return [];
185
- const client = options.client ?? defaultClient();
185
+ const client = options.client;
186
186
  const repositories = [...new Map(bindings.map((item) => [item.repositoryId, item])).values()].slice(0, 64);
187
187
  const graphs = options.background
188
188
  ? repositories.map((binding) => repositoryGraphJobs.read(
@@ -191,7 +191,7 @@ export async function projectRepositoryGraphs(
191
191
  options.priority ?? 0,
192
192
  ))
193
193
  : await Promise.all(repositories.map((binding) =>
194
- analyzeRepositoryGraph(client, projectId, binding.repositoryId, 30_000).then((graph) => {
194
+ analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000).then((graph) => {
195
195
  repositoryGraphJobs.remember(JSON.stringify([projectId, binding.repositoryId, binding.revision]), graph);
196
196
  return graph;
197
197
  })));
@@ -199,10 +199,13 @@ export async function projectRepositoryGraphs(
199
199
  }
200
200
 
201
201
  async function analyzeRepositoryGraph(
202
- client: EngineClientLike, projectId: string, repositoryId: string, timeoutMs: number,
202
+ client: EngineClientLike | undefined, projectId: string, repositoryId: string, timeoutMs: number,
203
203
  ): Promise<ProjectRepositoryGraph | undefined> {
204
+ // The daemon serves a socket serially. Analysis must not occupy the channel
205
+ // used for policy, bindings and Backbone reads while a repository is scanned.
206
+ const channel = client ?? new EngineClient({ socketPath: join(configDir(), "engine.sock") });
204
207
  try {
205
- const result = await client.request({
208
+ const result = await channel.request({
206
209
  operation: "graph.analyze_repository", input: {
207
210
  project_id: projectId, repository_id: repositoryId,
208
211
  },
@@ -251,6 +254,8 @@ async function analyzeRepositoryGraph(
251
254
  analysisStatus: "UNAVAILABLE", analysisErrorCode: code,
252
255
  nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false,
253
256
  };
257
+ } finally {
258
+ if (!client) channel.close();
254
259
  }
255
260
  }
256
261
 
@@ -0,0 +1,69 @@
1
+ import type { RelayClient } from "../../../../../../packages/core/relay-client";
2
+ import type { MeshSnapshot } from "../../../../../../packages/protocol/schema";
3
+ import { projectBackbone, projectRepositoryGraphs, waitForProjectBindingSync } from "../../../engine/runtime/engine-projects";
4
+ import { sendMeshPayload } from "../../../host/session-keys";
5
+ import { publishedCatalog } from "../../../session-catalog";
6
+ import { meshSnapshot, meshSnapshots } from "..";
7
+ import { refreshLocalGraphBindings } from "./binding-sync";
8
+
9
+ /** A person's explicit Graph refresh bypasses background job backoff. */
10
+ type GraphAnalysisDependencies = {
11
+ snapshot?: (projectId: string) => MeshSnapshot | undefined;
12
+ snapshots?: typeof meshSnapshots;
13
+ wait: typeof waitForProjectBindingSync;
14
+ sync?: typeof refreshLocalGraphBindings;
15
+ backbone: typeof projectBackbone;
16
+ graphs: typeof projectRepositoryGraphs;
17
+ send: (client: RelayClient, snapshot: MeshSnapshot) => Promise<void>;
18
+ };
19
+
20
+ const graphAnalysisDependencies: GraphAnalysisDependencies = {
21
+ snapshot: meshSnapshot,
22
+ wait: waitForProjectBindingSync,
23
+ sync: refreshLocalGraphBindings,
24
+ backbone: projectBackbone,
25
+ graphs: projectRepositoryGraphs,
26
+ send: (client, snapshot) => sendMeshPayload(client, snapshot, "phone", {
27
+ ttlMs: 60_000, reliable: true,
28
+ }),
29
+ };
30
+
31
+ export async function analyzeProjectGraphNow(
32
+ client: RelayClient, projectId: string,
33
+ dependencies: GraphAnalysisDependencies = graphAnalysisDependencies,
34
+ requestIds?: ReadonlySet<string>,
35
+ ): Promise<boolean> {
36
+ const known = publishedCatalog(client);
37
+ const read = dependencies.snapshot ?? ((id: string) => dependencies.snapshots?.().find(item => item.projectId === id));
38
+ const snapshot = dependencies === graphAnalysisDependencies
39
+ ? meshSnapshot(projectId, known ? [...known.sessions, ...(known.history ?? [])] : undefined)
40
+ : read(projectId);
41
+ if (!snapshot) return false;
42
+ await dependencies.wait(projectId);
43
+ const current = dependencies === graphAnalysisDependencies
44
+ ? meshSnapshot(projectId, known ? [...known.sessions, ...(known.history ?? [])] : undefined)
45
+ : read(projectId);
46
+ if (!current) return false;
47
+ const failures = dependencies.sync ? await dependencies.sync(current) : [];
48
+ const rejected = new Set(failures.map(report => report.repositoryId));
49
+ const [backbone, repositoryGraphs] = await Promise.all([
50
+ dependencies.backbone(projectId),
51
+ dependencies.graphs(projectId, (current.bindings ?? []).filter(binding => !rejected.has(binding.repositoryId)), { background: false }),
52
+ ]);
53
+ const observed = [...repositoryGraphs, ...failures];
54
+ const reports = observed.length > 0 ? observed : [{
55
+ projectId, repositoryId: current.project.canonicalRepositoryId,
56
+ revision: "unverified", weavatrixVersion: "unknown",
57
+ analysisStatus: "UNAVAILABLE" as const, analysisErrorCode: "REPOSITORY_NOT_BOUND",
58
+ nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false,
59
+ }];
60
+ const receipts = requestIds?.size ? requestIds : new Set<string | undefined>([undefined]);
61
+ for (const id of receipts) {
62
+ await dependencies.send(client, {
63
+ ...current, backbone, repositoryGraphs: reports, generatedAt: Date.now(),
64
+ ...(id ? { graphAnalysisRequestId: id } : {}),
65
+ });
66
+ }
67
+ return true;
68
+ }
69
+
@@ -10,9 +10,6 @@ import type {
10
10
  SessionInfo,
11
11
  TaskCapsule,
12
12
  } from "../../../../../packages/protocol/schema";
13
- import {
14
- projectBackbone, projectRepositoryGraphs, waitForProjectBindingSync,
15
- } from "../../engine/runtime/engine-projects";
16
13
  import { configDir } from "../../config";
17
14
  import {
18
15
  createClaudeSession,
@@ -25,10 +22,7 @@ import { isProviderEnabled } from "../../config/runtime";
25
22
  import { sendMeshPayload } from "../../host/session-keys";
26
23
  import { linkSessionsToProjects, workingTreeState } from "../catalog";
27
24
  import { catalogFromEndpoint } from "../catalog/models";
28
- import { projectMcpServers } from "../catalog/project/capabilities";
29
- import { configuredProjectMcpServers, mergeConfiguredMcpServers } from "../catalog/project/configured-mcp";
30
25
  import { saveProjectCapabilityRequest } from "../catalog/project/requests";
31
- import { projectCapabilityObservations } from "../catalog/project/observations";
32
26
  import { computerId } from "../identity/computer";
33
27
  import { createCheckpoint } from "../admin/checkpoint";
34
28
  import { buildTaskCapsule } from "../admin/capsule";
@@ -36,11 +30,11 @@ import { handoffReadiness } from "../tasks/readiness";
36
30
  import { createHandoffFlow } from "./handoff";
37
31
  import type { MeshRuntimeDependencies } from "./dependencies";
38
32
  import { localMeshStore } from "../local-remote/local";
33
+ import { composeMeshSnapshot } from "./snapshot/compose";
39
34
  import { enrichMeshSnapshot } from "./snapshot/enriched";
40
35
  import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
41
36
  import { fetchRevision, pushBranch } from "../local-remote/remote";
42
- import { projectSessionCapabilityInventory, projectExecutionCapabilitySessions } from "./capabilities";
43
- import { refreshLocalGraphBindings } from "./graph/binding-sync";
37
+ import { projectSessionCapabilityInventory } from "./capabilities";
44
38
  import { createMeshSessionCatalog } from "./catalog/session-catalog";
45
39
 
46
40
  export type { MeshRuntimeDependencies };
@@ -158,53 +152,29 @@ export function createMeshRuntime(deps: MeshRuntimeDependencies) {
158
152
  const catalog = (sessions: SessionInfo[]) =>
159
153
  linkSessionsToProjects(deps.store(), sessions, deps.computer());
160
154
 
161
- const composeSnapshot = (
162
- projectId: string,
163
- store = deps.store(),
164
- sessions = deps.sessions(),
165
- models = catalogFromEndpoint(deps.computer(), sessions),
166
- ): MeshSnapshot | undefined => {
167
- const snapshot = store.snapshot(projectId, deps.computer());
168
- if (!snapshot) return undefined;
169
- const linked = projectExecutionCapabilitySessions(
170
- snapshot, sessions, deps.computer(), deps.capabilityInventory,
171
- );
172
- const reported = projectMcpServers(
173
- linked.filter((session) => session.computerId === deps.computer()), projectId,
174
- );
175
- const workspaces = (snapshot.bindings ?? [])
176
- .filter((binding) => binding.endpointId === deps.computer()
177
- && binding.available && binding.localPathHint)
178
- .map((binding) => binding.localPathHint!);
179
- const mcpServers = mergeConfiguredMcpServers(
180
- configuredProjectMcpServers(deps.computer(), workspaces), reported,
181
- );
182
- const capabilityObservations = projectCapabilityObservations({
183
- projectId, endpointId: deps.computer(), bound: workspaces.length > 0,
184
- requests: snapshot.capabilityRequests ?? [], skills: snapshot.skills ?? [],
185
- mcpServers, now: deps.now(),
186
- });
187
- return {
188
- ...snapshot,
189
- publisherEndpointId: deps.computer(),
190
- mcpServers: mcpServers.length > 0 ? mcpServers : undefined,
191
- capabilityObservations: capabilityObservations.length > 0
192
- ? capabilityObservations : undefined,
193
- modelCatalog: models.models.length > 0 || models.reason ? [models] : undefined,
194
- };
195
- };
196
155
 
197
156
  return {
198
157
  catalog,
199
- snapshot(projectId: string): MeshSnapshot | undefined {
200
- return composeSnapshot(projectId);
158
+ snapshot(projectId: string, sessions?: SessionInfo[]): MeshSnapshot | undefined {
159
+ return composeMeshSnapshot(deps, projectId, deps.store(), sessions ?? deps.sessions());
201
160
  },
202
161
  snapshots(): MeshSnapshot[] {
203
162
  const store = deps.store();
204
163
  const sessions = deps.sessions();
205
164
  const models = catalogFromEndpoint(deps.computer(), sessions);
206
165
  return store.projectIds().flatMap((projectId) =>
207
- composeSnapshot(projectId, store, sessions, models) ?? []);
166
+ composeMeshSnapshot(deps, projectId, store, sessions, models) ?? []);
167
+ },
168
+ async snapshotsAsync(sessions = deps.sessions()): Promise<MeshSnapshot[]> {
169
+ const store = deps.store();
170
+ const models = catalogFromEndpoint(deps.computer(), sessions);
171
+ const snapshots: MeshSnapshot[] = [];
172
+ for (const id of store.projectIds()) {
173
+ await new Promise<void>(resolve => setImmediate(resolve));
174
+ const snapshot = composeMeshSnapshot(deps, id, store, sessions, models);
175
+ if (snapshot) snapshots.push(snapshot);
176
+ }
177
+ return snapshots;
208
178
  },
209
179
  requestCapability(input: ProjectCapabilityRequestSet): boolean {
210
180
  const project = deps.store().snapshot(input.projectId)?.project;
@@ -251,16 +221,21 @@ export function meshSnapshots(): MeshSnapshot[] {
251
221
  return defaultRuntime.snapshots();
252
222
  }
253
223
 
254
- export function meshSnapshot(projectId: string): MeshSnapshot | undefined {
255
- return defaultRuntime.snapshot(projectId);
224
+ /** Yield between Project projections so live decisions and heartbeats continue. */
225
+ export function meshSnapshotsAsync(sessions?: SessionInfo[]): Promise<MeshSnapshot[]> {
226
+ return defaultRuntime.snapshotsAsync(sessions);
227
+ }
228
+
229
+ export function meshSnapshot(projectId: string, sessions?: SessionInfo[]): MeshSnapshot | undefined {
230
+ return defaultRuntime.snapshot(projectId, sessions);
256
231
  }
257
232
 
258
233
  export function requestProjectCapability(input: ProjectCapabilityRequestSet): boolean {
259
234
  return defaultRuntime.requestCapability(input);
260
235
  }
261
236
 
262
- export async function meshSnapshotsWithEngine(): Promise<MeshSnapshot[]> {
263
- return Promise.all(meshSnapshots().map((snapshot) => enrichMeshSnapshot(snapshot)));
237
+ export async function meshSnapshotsWithEngine(snapshots = meshSnapshots()): Promise<MeshSnapshot[]> {
238
+ return Promise.all(snapshots.map((snapshot) => enrichMeshSnapshot(snapshot)));
264
239
  }
265
240
 
266
241
  export async function meshSnapshotWithEngine(projectId: string): Promise<MeshSnapshot | undefined> {
@@ -268,52 +243,7 @@ export async function meshSnapshotWithEngine(projectId: string): Promise<MeshSna
268
243
  return snapshot ? enrichMeshSnapshot(snapshot) : undefined;
269
244
  }
270
245
 
271
- /** A person's explicit Graph refresh bypasses background job backoff. */
272
- type GraphAnalysisDependencies = {
273
- snapshots: typeof meshSnapshots;
274
- wait: typeof waitForProjectBindingSync;
275
- sync?: typeof refreshLocalGraphBindings;
276
- backbone: typeof projectBackbone;
277
- graphs: typeof projectRepositoryGraphs;
278
- send: (client: RelayClient, snapshot: MeshSnapshot) => Promise<void>;
279
- };
280
-
281
- const graphAnalysisDependencies: GraphAnalysisDependencies = {
282
- snapshots: meshSnapshots,
283
- wait: waitForProjectBindingSync,
284
- sync: refreshLocalGraphBindings,
285
- backbone: projectBackbone,
286
- graphs: projectRepositoryGraphs,
287
- send: (client, snapshot) => sendMeshPayload(client, snapshot, "phone", {
288
- ttlMs: 60_000, reliable: true,
289
- }),
290
- };
291
-
292
- export async function analyzeProjectGraphNow(
293
- client: RelayClient, projectId: string,
294
- dependencies: GraphAnalysisDependencies = graphAnalysisDependencies,
295
- ): Promise<boolean> {
296
- const snapshot = dependencies.snapshots().find((item) => item.projectId === projectId);
297
- if (!snapshot) return false;
298
- await dependencies.wait(projectId);
299
- const current = dependencies.snapshots().find((item) => item.projectId === projectId);
300
- if (!current) return false;
301
- if (dependencies.sync) await dependencies.sync(current);
302
- const [backbone, repositoryGraphs] = await Promise.all([
303
- dependencies.backbone(projectId),
304
- dependencies.graphs(projectId, current.bindings ?? [], { background: false }),
305
- ]);
306
- const reports = repositoryGraphs.length > 0 ? repositoryGraphs : [{
307
- projectId, repositoryId: current.project.canonicalRepositoryId,
308
- revision: "unverified", weavatrixVersion: "unknown",
309
- analysisStatus: "UNAVAILABLE" as const, analysisErrorCode: "REPOSITORY_NOT_BOUND",
310
- nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false,
311
- }];
312
- await dependencies.send(client, {
313
- ...current, backbone, repositoryGraphs: reports, generatedAt: Date.now(),
314
- });
315
- return true;
316
- }
246
+ export { analyzeProjectGraphNow } from "./graph/analysis";
317
247
 
318
248
  export function handleMeshPayload(
319
249
  client: RelayClient,
@@ -0,0 +1,45 @@
1
+ import type { MeshSnapshot } from "../../../../../../packages/protocol/schema";
2
+ import type { MeshRuntimeDependencies } from "../dependencies";
3
+ import { catalogFromEndpoint } from "../../catalog/models";
4
+ import { projectMcpServers } from "../../catalog/project/capabilities";
5
+ import { configuredProjectMcpServers, mergeConfiguredMcpServers } from "../../catalog/project/configured-mcp";
6
+ import { projectCapabilityObservations } from "../../catalog/project/observations";
7
+ import { projectExecutionCapabilitySessions } from "../capabilities";
8
+
9
+ export function composeMeshSnapshot(
10
+ deps: MeshRuntimeDependencies,
11
+ projectId: string,
12
+ store = deps.store(),
13
+ sessions = deps.sessions(),
14
+ models = catalogFromEndpoint(deps.computer(), sessions),
15
+ ): MeshSnapshot | undefined {
16
+ const snapshot = store.snapshot(projectId, deps.computer());
17
+ if (!snapshot) return undefined;
18
+ const linked = projectExecutionCapabilitySessions(
19
+ snapshot, sessions, deps.computer(), deps.capabilityInventory,
20
+ );
21
+ const reported = projectMcpServers(
22
+ linked.filter((session) => session.computerId === deps.computer()), projectId,
23
+ );
24
+ const workspaces = (snapshot.bindings ?? [])
25
+ .filter((binding) => binding.endpointId === deps.computer()
26
+ && binding.available && binding.localPathHint)
27
+ .map((binding) => binding.localPathHint!);
28
+ const mcpServers = mergeConfiguredMcpServers(
29
+ configuredProjectMcpServers(deps.computer(), workspaces), reported,
30
+ );
31
+ const capabilityObservations = projectCapabilityObservations({
32
+ projectId, endpointId: deps.computer(), bound: workspaces.length > 0,
33
+ requests: snapshot.capabilityRequests ?? [], skills: snapshot.skills ?? [],
34
+ mcpServers, now: deps.now(),
35
+ });
36
+ return {
37
+ ...snapshot,
38
+ publisherEndpointId: deps.computer(),
39
+ mcpServers: mcpServers.length > 0 ? mcpServers : undefined,
40
+ capabilityObservations: capabilityObservations.length > 0
41
+ ? capabilityObservations : undefined,
42
+ modelCatalog: models.models.length > 0 || models.reason ? [models] : undefined,
43
+ };
44
+ }
45
+
@@ -40,7 +40,8 @@ export async function enrichMeshSnapshot(
40
40
  ...(snapshot.supersededKnowledgeRecordIds ?? []),
41
41
  ...supersededProjectKnowledgeIds(snapshot.projectId, allMemory),
42
42
  ])].sort().slice(-128);
43
- const enriched = { ...snapshot, backbone, repositoryGraphs: [...repositoryGraphs, ...identityFailures],
43
+ const enriched = { ...snapshot, backbone, ...(repositoryGraphs.length || identityFailures.length
44
+ ? { repositoryGraphs: [...repositoryGraphs, ...identityFailures] } : {}),
44
45
  repositoryDetails: details,
45
46
  knowledge: activeProjectKnowledge(snapshot.projectId, allMemory)
46
47
  .sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 16),
@@ -0,0 +1,13 @@
1
+ # Session monitor
2
+
3
+ `index.ts` owns discovery, subscriptions and phone delivery for this computer.
4
+ Chat catalogs publish independently of background Mesh enrichment. The Mesh
5
+ publisher reuses known native identities and yields between Projects.
6
+
7
+ Architecture responses acknowledge their explicit request. Coalesced builds
8
+ retain every controller's receipt; periodic cached reports remain ordinary Mesh
9
+ snapshots. Repository analysis uses its own Engine socket to keep policy and
10
+ Backbone requests responsive.
11
+
12
+ Integration tests remain in `tests/monitor`; background publication tests live
13
+ beside `mesh-publication`. This module uses the repository MIT License.
@@ -1,16 +1,19 @@
1
1
  import type { RelayClient } from "../../../../packages/core/relay-client";
2
2
  import { analyzeProjectGraphNow } from "../mesh/runtime";
3
3
 
4
- const inFlight = new Set<string>();
4
+ const inFlight = new Map<string, Set<string>>();
5
5
 
6
6
  /** One user request starts one analysis per Project, even during reconnect. */
7
7
  export function requestProjectGraphRefresh(
8
8
  client: RelayClient, projectId: string,
9
9
  analyze: typeof analyzeProjectGraphNow = analyzeProjectGraphNow,
10
+ requestId?: string,
10
11
  ): boolean {
11
- if (inFlight.has(projectId)) return false;
12
- inFlight.add(projectId);
13
- void analyze(client, projectId).catch(() => false).finally(() => {
12
+ const pending = inFlight.get(projectId);
13
+ if (pending) { if (requestId) pending.add(requestId); return false; }
14
+ const receipts = new Set(requestId ? [requestId] : []);
15
+ inFlight.set(projectId, receipts);
16
+ void analyze(client, projectId, undefined, receipts).catch(() => false).finally(() => {
14
17
  inFlight.delete(projectId);
15
18
  });
16
19
  return true;
@@ -172,10 +172,10 @@ async function handleInboundRefresh(
172
172
  // An explicit refresh may pay for the full history scan; routine Mesh
173
173
  // snapshots use the cached history and fresh live sessions.
174
174
  invalidateMeshSessionHistory();
175
- await publish(true).catch(() => {});
176
175
  if (payload.graphProjectId && loadRuntimeConfig().meshEnabled) {
177
- requestProjectGraphRefresh(client, payload.graphProjectId);
176
+ requestProjectGraphRefresh(client, payload.graphProjectId, undefined, payload.graphAnalysisRequestId);
178
177
  }
178
+ void publish(true).catch(() => {});
179
179
  return true;
180
180
  }
181
181
 
@@ -0,0 +1,9 @@
1
+ # Mesh publication
2
+
3
+ `index.ts` owns one background Mesh publication per monitor transport. Project
4
+ projection yields to live decisions and uses the observed native catalog. Plain
5
+ snapshots, policy reports, and Engine enrichment remain separate from fresh chat
6
+ catalog delivery. Monitor close stops publication from the old transport.
7
+
8
+ Tests in `tests/` exercise current catalog progress while Mesh delivery is held.
9
+ This module uses the repository MIT License.
@@ -0,0 +1,45 @@
1
+ import type { RelayClient } from "../../../../../packages/core/relay-client";
2
+ import type { SessionsStatus } from "../../../../../packages/protocol/schema";
3
+ import { localMeshStore } from "../../mesh/local-remote/local";
4
+ import { meshSnapshotsAsync, meshSnapshotsWithEngine } from "../../mesh/runtime";
5
+ import { deriveObservedClaims } from "../../mesh/observed/claims";
6
+ import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
7
+ import { publishProjectPolicyStatuses } from "../../project-policy/runtime";
8
+ import { publishedCatalog } from "../../session-catalog";
9
+ import { sendMeshPayload } from "../../host/session-keys";
10
+ import { INTERVAL_MS } from "../handlers/catalog";
11
+
12
+ const pending = new WeakMap<RelayClient, { active: boolean }>();
13
+
14
+ /** One background projection per transport; it never holds the current chat catalog. */
15
+ export function publishMonitorMeshes(client: RelayClient, status: SessionsStatus): void {
16
+ if (pending.has(client)) return;
17
+ const token = { active: true };
18
+ pending.set(client, token);
19
+ const work = (async () => {
20
+ await new Promise<void>(resolve => setImmediate(resolve));
21
+ if (!token.active || !client.isConnected) return;
22
+ deriveObservedClaims(localMeshStore(), status.sessions);
23
+ void ingestRuntimeInvocations(status.sessions).catch(() => {});
24
+ const known = publishedCatalog(client);
25
+ const snapshots = await meshSnapshotsAsync([...status.sessions, ...(known?.history ?? [])]);
26
+ for (const snapshot of snapshots) {
27
+ if (!token.active || !client.isConnected) return;
28
+ await sendMeshPayload(client, snapshot, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false }).catch(() => {});
29
+ }
30
+ if (!token.active || !client.isConnected) return;
31
+ await publishProjectPolicyStatuses(client, snapshots.map(mesh => mesh.projectId)).catch(() => {});
32
+ const enriched = await meshSnapshotsWithEngine(snapshots);
33
+ for (const snapshot of enriched) {
34
+ if (!token.active || !client.isConnected) return;
35
+ await sendMeshPayload(client, snapshot, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false }).catch(() => {});
36
+ }
37
+ })();
38
+ void work.catch(() => {}).finally(() => { if (pending.get(client) === token) pending.delete(client); });
39
+ }
40
+
41
+ export function stopMonitorMeshes(client: RelayClient): void {
42
+ const token = pending.get(client);
43
+ if (token) token.active = false;
44
+ pending.delete(client);
45
+ }
@@ -12,10 +12,7 @@ import { localMeshStore } from "../../mesh/local-remote/local";
12
12
  import { refreshMcpLoad } from "../../machine-load/mcp/refresh";
13
13
  import { approvalsStatus } from "../../approvals/state";
14
14
  import { primeSessionKeys } from "../../host/session-keys";
15
- import { sendMeshPayload } from "../../host/session-keys";
16
- import { meshCatalog, meshSnapshots, meshSnapshotsWithEngine } from "../../mesh/runtime";
17
- import { deriveObservedClaims } from "../../mesh/observed/claims";
18
- import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
15
+ import { meshCatalog } from "../../mesh/runtime";
19
16
  import { HEARTBEAT_INTERVAL_MS, publishHeartbeat } from "./heartbeat";
20
17
  import { confirmPendingController } from "../../pairing/controllers";
21
18
  import { recordControllerName } from "../../pairing/presence";
@@ -24,14 +21,13 @@ import { singleFlightPublisher } from "./single-flight";
24
21
  import { publishedCatalog, rememberPublishedCatalog, resumePublishedChats } from "../../session-catalog";
25
22
  import { createMachineLoadPublisher } from "../../machine-load";
26
23
  import { startMachineLoadLoop } from "../../machine-load/host/loop";
27
- import { publishProjectPolicyStatuses } from "../../project-policy/runtime";
28
24
  import { scanCapabilityUsage, scanSessionHistory, scanSessions, scopeCapabilityUsageToRoom, TOKEN_WINDOW_HOURS } from "../../sessions";
29
25
  import { boundedCatalogHistory, INTERVAL_MS, publishSessionEvents } from "../handlers/catalog";
30
26
  import { sweepAttachments } from "../handlers/receipts";
31
27
  import { handleMonitorMessage } from "../handlers/inbound";
32
28
 
33
29
  const HISTORY_INTERVAL_MS = 10_000;
34
- let meshEnrichmentInFlight: Promise<void> | undefined;
30
+ import { publishMonitorMeshes, stopMonitorMeshes } from "../mesh-publication";
35
31
 
36
32
  export type SessionMonitor = {
37
33
  publish: () => Promise<void>;
@@ -140,22 +136,7 @@ async function publishMonitorTick(input: {
140
136
  await publishSessionEvents(client, sessionId, status).catch(() => false);
141
137
  }
142
138
  await client.send(approvalsStatus(), "phone", { ttlMs: INTERVAL_MS * 3, reliable: false });
143
- if (loadRuntimeConfig().meshEnabled) {
144
- deriveObservedClaims(localMeshStore(), status.sessions);
145
- void ingestRuntimeInvocations(status.sessions).catch(() => {});
146
- const meshes = meshSnapshots();
147
- for (const mesh of meshes) {
148
- await sendMeshPayload(client, mesh, "phone", {
149
- ttlMs: INTERVAL_MS * 24,
150
- reliable: false,
151
- }).catch(() => {});
152
- }
153
- await publishProjectPolicyStatuses(
154
- client,
155
- meshes.map((mesh) => mesh.projectId),
156
- ).catch(() => {});
157
- publishEnrichedMeshes(client);
158
- }
139
+ if (loadRuntimeConfig().meshEnabled) publishMonitorMeshes(client, status);
159
140
  if (Date.now() - lastCapabilityPublishedAt >= 30_000) {
160
141
  const usage = scanCapabilityUsage([
161
142
  ...status.sessions,
@@ -167,18 +148,6 @@ async function publishMonitorTick(input: {
167
148
  return { lastHistoryPublishedAt, lastCapabilityPublishedAt };
168
149
  }
169
150
 
170
- function publishEnrichedMeshes(client: RelayClient): void {
171
- if (meshEnrichmentInFlight) return;
172
- meshEnrichmentInFlight = (async () => {
173
- const meshes = await meshSnapshotsWithEngine();
174
- for (const mesh of meshes) {
175
- await sendMeshPayload(client, mesh, "phone", {
176
- ttlMs: INTERVAL_MS * 24, reliable: false,
177
- }).catch(() => {});
178
- }
179
- })().finally(() => { meshEnrichmentInFlight = undefined; });
180
- }
181
-
182
151
  /**
183
152
  * Attach task discovery and phone-to-agent delivery to the same relay client
184
153
  * used by MCP tools. This keeps STDIO stdout clean and removes the old need for
@@ -254,6 +223,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
254
223
  publish: () => publish(false),
255
224
  close: () => {
256
225
  stopCatalog();
226
+ stopMonitorMeshes(client);
257
227
  stopHeartbeat();
258
228
  loadLoop.stop();
259
229
  off();
@@ -2,7 +2,8 @@
2
2
 
3
3
  `index.ts` keeps the native identities of the latest published catalog. Requests
4
4
  for a chat already shown on the phone use that identity instead of walking every
5
- provider directory again. A new discovery window cannot make a known chat
5
+ provider directory again, including image chunk requests. The in-memory catalog
6
+ retains current names; the private restart cache contains only native identity. A new discovery window cannot make a known chat
6
7
  unreadable merely because hundreds of newer logs appeared.
7
8
 
8
9
  The private `session-catalog-cache.json` contains metadata and validated native
@@ -20,10 +20,12 @@ function metadata(session: SessionInfo): SessionInfo {
20
20
 
21
21
  /** Keep native identity, not capability descriptors or message contents. */
22
22
  export function rememberPublishedCatalog(client: RelayClient, status: SessionsStatus): void {
23
+ const previous = catalogs.get(client);
24
+ const history = status.history ?? previous?.history;
23
25
  const compact: SessionsStatus = { type: "sessions.status", machine: status.machine,
24
- sessions: status.sessions.map(metadata), history: status.history?.map(metadata),
26
+ sessions: status.sessions.map(metadata), history: history?.map(metadata),
25
27
  generatedAt: status.generatedAt };
26
- catalogs.set(client, compact);
28
+ catalogs.set(client, { ...status, history });
27
29
  const paths = Object.fromEntries([...compact.sessions, ...(compact.history ?? [])]
28
30
  .flatMap(session => { const path = nativeTranscriptSource(session);
29
31
  return path ? [[session.sessionId, path]] : []; }));
@@ -12,3 +12,8 @@ account state. Apple clients retain validated original bytes locally and use
12
12
  their scoped copy when offline. Tests live in `tests/`.
13
13
 
14
14
  Licensed under the MIT License. See the repository root `LICENSE`.
15
+
16
+ Native image identities remain readable after the chat head advances. Codex
17
+ attachments are located by timestamp and content block together, so a caption
18
+ with the same timestamp cannot hide the picture. Original bytes are delivered in
19
+ bounded encrypted chunks and the Apple client retains them for offline use.
@@ -3,6 +3,7 @@ import { SessionImageChunk } from '../../../../packages/protocol/schema';
3
3
  import type { RelayClient } from '../../../../packages/core/relay-client';
4
4
  import { sendSessionPayload } from '../host/session-keys';
5
5
  import { readTranscriptHistory } from '../transcript-history';
6
+ import { publishedSession } from '../session-catalog';
6
7
  import { cachedSessionActivity } from '../monitor/support/session-activity';
7
8
  import { scanSessions, scanSessionHistory } from '../sessions';
8
9
  import { sessionImageChunk, transcriptImages } from '../../../mcp/src/desktop/image';
@@ -22,7 +23,8 @@ export function readSessionImage(query: SessionImageQuery, session: SessionInfo,
22
23
  }
23
24
 
24
25
  export async function publishSessionImage(client: RelayClient, query: SessionImageQuery): Promise<boolean> {
25
- const session = scanSessions().sessions.find(s => s.sessionId === query.sessionId)
26
+ const session = publishedSession(client, query.sessionId)
27
+ ?? scanSessions().sessions.find(s => s.sessionId === query.sessionId)
26
28
  ?? scanSessionHistory().find(s => s.sessionId === query.sessionId);
27
29
  const activity = session ? readTranscriptHistory(session, query.historyCursor)
28
30
  ?? cachedSessionActivity(session) : undefined;
@@ -21,14 +21,16 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
21
21
  }
22
22
  if (!path || !['claude', 'codex'].includes(session.agent)) return undefined;
23
23
  try {
24
- const lines = previousNativeLines(path, session.sessionId, cursor)?.lines.map(l => l.text);
25
- if (!lines) return undefined;
26
- if (session.agent === 'codex') {
27
- const full = codexImageChunk(session.sessionId, imageId, 0, lines);
28
- if (!full) return undefined;
29
- // The native reader retains the original bytes for subsequent bounded chunks.
30
- return codexImageChunk(session.sessionId, imageId, offset);
31
- }
24
+ let pageCursor = cursor;
25
+ for (let page = 0; page < 32; page++) {
26
+ const window = previousNativeLines(path, session.sessionId, pageCursor);
27
+ if (!window) return undefined;
28
+ const lines = window.lines.map(line => line.text);
29
+ if (session.agent === 'codex') {
30
+ const full = codexImageChunk(session.sessionId, imageId, 0, lines)
31
+ ?? codexImageChunk(session.sessionId, imageId, 0);
32
+ return full ? codexImageChunk(session.sessionId, imageId, offset) : undefined;
33
+ }
32
34
  for (const line of lines) {
33
35
  let row;
34
36
  try { row = JSON.parse(line); } catch { continue; }
@@ -46,6 +48,9 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
46
48
  return slice(cached, offset);
47
49
  }
48
50
  }
51
+ if (window.start === 0) break;
52
+ pageCursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
53
+ }
49
54
  } catch { return undefined; }
50
55
  return undefined;
51
56
  }
@@ -5,9 +5,10 @@ import { previousNativeLines } from "./native-lines";
5
5
  /** Locate an older image row without copying image bytes into chat pages. */
6
6
  export function nativeImageLines(sessionId: string, entryId: string): string[] | undefined {
7
7
  if (!entryId.startsWith(`${sessionId}:`)) return undefined;
8
- const stamp = Number(entryId.slice(sessionId.length + 1).split(":")[0]);
8
+ const [stampText, blockText] = entryId.slice(sessionId.length + 1).split(":");
9
+ const stamp = Number(stampText), blockIndex = Number(blockText);
9
10
  const path = codexLogPathBySession.get(sessionId);
10
- if (!path || !Number.isFinite(stamp)) return undefined;
11
+ if (!path || !Number.isFinite(stamp) || !Number.isSafeInteger(blockIndex) || blockIndex < 0) return undefined;
11
12
  let cursor: string | undefined;
12
13
  try {
13
14
  for (let page = 0; page < 32; page++) {
@@ -19,7 +20,8 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
19
20
  const createdAt = ts(row?.timestamp);
20
21
  if (createdAt) earliest = Math.min(earliest, createdAt);
21
22
  if (createdAt === stamp && row?.type === "response_item" && p?.type === "message"
22
- && p.role === "user" && Array.isArray(p.content)) return [line.text];
23
+ && p.role === "user" && Array.isArray(p.content)
24
+ && p.content[blockIndex]?.type === "input_image") return [line.text];
23
25
  }
24
26
  if (window.start === 0 || earliest < stamp) return undefined;
25
27
  cursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
@@ -15,7 +15,10 @@ export function sessionImageChunk(session: SessionInfo, activity: SessionActivit
15
15
  if (activity.sessionId !== session.sessionId) return undefined;
16
16
  const native = activity.entries.some(entry => entry.id === imageId && entry.kind === 'user'
17
17
  && entry.attachments?.includes('Image'));
18
- if (native) return nativeTranscriptImage(session, imageId, offset, cursor);
18
+ if (native || imageId.startsWith(`${session.sessionId}:`)) {
19
+ const picture = nativeTranscriptImage(session, imageId, offset, cursor);
20
+ if (picture) return picture;
21
+ }
19
22
  const artifact = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
20
23
  if (!artifact) return undefined;
21
24
  const loaded = readStoreState(storePath ?? join(configDir(), 'project-mesh.json'));
@@ -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.47
13
+ npm install -g granttap-mcp@0.8.48
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.47\";\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.48\";\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.47";
10
+ const PACKAGE = "granttap-mcp@0.8.48";
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.47",
3
+ "version": "0.8.48",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -144,5 +144,6 @@ export const SessionsRefresh = z.object({
144
144
  type: z.literal("sessions.refresh"),
145
145
  createdAt: z.number(),
146
146
  graphProjectId: z.string().min(1).max(128).optional(),
147
+ graphAnalysisRequestId: z.string().min(1).max(128).optional(),
147
148
  });
148
149
  export type SessionsRefresh = z.infer<typeof SessionsRefresh>;
@@ -250,6 +250,7 @@ export const MeshSnapshot = z.object({
250
250
  dependencies: z.array(TaskDependency).max(128),
251
251
  events: z.array(MeshEvent).max(128),
252
252
  generatedAt: z.number().nonnegative(),
253
+ graphAnalysisRequestId: z.string().min(1).max(128).optional(),
253
254
  }).strict().superRefine((value, ctx) => {
254
255
  const detailKeys = new Set(value.repositoryDetails?.map((item) => `${item.endpointId}\0${item.repositoryId}`));
255
256
  if (detailKeys.size !== (value.repositoryDetails?.length ?? 0)