granttap-mcp 0.8.54 → 0.8.56

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 (42) hide show
  1. package/README.md +21 -0
  2. package/apps/bridge/src/engine/README.md +5 -0
  3. package/apps/bridge/src/engine/runtime/engine-projects.ts +15 -10
  4. package/apps/bridge/src/engine/runtime/repository-graph/cache.ts +54 -0
  5. package/apps/bridge/src/engine/runtime/repository-graph-jobs.ts +47 -5
  6. package/apps/bridge/src/host/session-key-store/README.md +12 -0
  7. package/apps/bridge/src/host/session-key-store/index.ts +102 -0
  8. package/apps/bridge/src/host/session-keys.ts +5 -29
  9. package/apps/bridge/src/monitor/handlers/catalog.ts +2 -1
  10. package/apps/bridge/src/monitor/handlers/inbound.ts +12 -7
  11. package/apps/bridge/src/monitor/mesh-publication/README.md +3 -0
  12. package/apps/bridge/src/monitor/mesh-publication/enrichment.ts +11 -0
  13. package/apps/bridge/src/monitor/mesh-publication/index.ts +4 -4
  14. package/apps/bridge/src/session-catalog/README.md +2 -0
  15. package/apps/bridge/src/session-images/README.md +6 -0
  16. package/apps/bridge/src/session-images/index.ts +3 -2
  17. package/apps/bridge/src/task-files/README.md +16 -0
  18. package/apps/bridge/src/task-files/index.ts +96 -0
  19. package/apps/bridge/src/task-files/relay.ts +14 -0
  20. package/apps/bridge/src/transcript-history/images/advertised-entry.ts +43 -0
  21. package/apps/bridge/src/transcript-history/images/native.ts +5 -2
  22. package/apps/bridge/src/transcript-history/images.ts +6 -2
  23. package/apps/bridge/src/transcript-history/index.ts +1 -0
  24. package/apps/mcp/src/desktop/engine-bridge.ts +1 -1
  25. package/apps/mcp/src/desktop/image/index.ts +17 -8
  26. package/apps/mcp/src/desktop/operations/index.ts +2 -0
  27. package/apps/mcp/src/desktop/workspace/README.md +8 -0
  28. package/apps/mcp/src/desktop/workspace/task-files.ts +30 -0
  29. package/cursor-plugin/README.md +1 -1
  30. package/cursor-plugin/mcp.json +1 -1
  31. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  32. package/docs/images/iphone-chat-files.png +0 -0
  33. package/docs/images/iphone-chat-work-summary.png +0 -0
  34. package/package.json +1 -1
  35. package/packages/core/outbound-scheduler/README.md +9 -7
  36. package/packages/core/outbound-scheduler/index.ts +0 -1
  37. package/packages/core/relay-client-types.ts +2 -0
  38. package/packages/core/relay-client.ts +6 -9
  39. package/packages/protocol/messages/mesh/snapshot.ts +1 -0
  40. package/packages/protocol/messages/sessions/workspace.ts +13 -0
  41. package/packages/protocol/schema.ts +4 -0
  42. package/packages/core/outbound-scheduler/active-controllers.ts +0 -15
package/README.md CHANGED
@@ -23,6 +23,12 @@ index so a restart can send current heads of the saved Now chats before a fresh
23
23
  discovery scan. The Apple clients keep downloaded conversations, pictures, and
24
24
  Mesh data locally for offline use; clearing that cache preserves the account login.
25
25
 
26
+ The chat's **Files** pill opens recorded changes for the current reply and the
27
+ Task's **All files** tree. Directory and text reads are paged, encrypted for the
28
+ requesting controller, and restricted to that native execution's admitted
29
+ checkout. Received previews remain on the device for offline inspection.
30
+ **Agents** opens nested execution conversations within the same Task.
31
+
26
32
  The MCP runtime, plugins, and CLI are open source under the MIT License.
27
33
  The Apple apps, Engine, relay, and website are separately licensed commercial
28
34
  products or source. Their licenses do not change this repository's MIT grant.
@@ -150,6 +156,21 @@ separate from their path identity. Missing content evidence remains unknown.
150
156
 
151
157
  <p align="center"><em>These are deterministic simulator fixtures, not reports from a live customer repository.</em></p>
152
158
 
159
+ Health and Graph share one architecture build state. With Engine enabled,
160
+ active repository bindings schedule missing or changed analysis automatically;
161
+ manual rebuilds join pending work and return their own delivery receipts.
162
+ Usable reports persist privately on the computer across MCP restarts, while
163
+ the phone keeps its last received map visible during a rebuild. Source Git
164
+ revision and analysis content fingerprint are separate freshness facts.
165
+ Partial and unavailable reports retain their scope and status.
166
+
167
+ <p align="center">
168
+ <img src="docs/images/iphone-chat-work-summary.png" width="200" alt="Files and Agents above the composer with deterministic sample work">
169
+ <img src="docs/images/iphone-chat-files.png" width="200" alt="Saved Task checkout folder tree">
170
+ </p>
171
+
172
+ <p align="center"><em>Native build181 captures with deterministic sample data.</em></p>
173
+
153
174
  An authenticated controller can request a separate, one-time device credential
154
175
  for another iPhone or iPad from each Live linked computer. The phone packages
155
176
  the resulting links in an encrypted, single-use QR. This adds the new phone to
@@ -16,6 +16,11 @@ 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
17
  policy IPC: repository analysis owns a separate bounded-lifetime socket, while
18
18
  policy and Backbone requests keep their control connection. It does not turn a repository label into an architecture result.
19
+ Explicit builds join and prioritize the same pending repository job. Confirmed
20
+ reports are retained in a bounded private computer cache across restarts, served
21
+ while a new observation runs, and refreshed after the observation expires. Git
22
+ admission revision (`sourceRevision`) remains separate from Weavatrix's content
23
+ fingerprint (`revision`). Failed analyses cannot erase the durable last report.
19
24
  Claude, Codex, and Cursor hooks can evaluate the engine's local Project
20
25
  Governance policy only when both `GRANTTAP_ENGINE_ENABLED` and
21
26
  `GRANTTAP_PROJECT_POLICY_ENABLED` are enabled. Project `DENY` and `ASK` precede
@@ -16,6 +16,7 @@ import type {
16
16
  } from "../protocol/engine-protocol";
17
17
  import { RepositoryGraphJobs } from "./repository-graph-jobs";
18
18
  import { fitRepositoryGraph } from "./repository-graph/fit";
19
+ import { RepositoryGraphCache } from "./repository-graph/cache";
19
20
 
20
21
  export type LocalProjectBinding = {
21
22
  summary: ProjectBindingSummary;
@@ -27,9 +28,11 @@ export type LocalProjectBinding = {
27
28
 
28
29
  let sharedClient: EngineClient | undefined;
29
30
  const pendingBindingSyncs = new Map<string, Promise<boolean>>();
30
- const MAX_REPOSITORY_GRAPH_WIRE_BYTES = 256 * 1_024;
31
+ // The Apple Mesh envelope is 256KiB; leave room for Tasks, bindings and receipts.
32
+ const MAX_REPOSITORY_GRAPH_WIRE_BYTES = 192 * 1_024 - 128;
31
33
  const MAX_SINGLE_GRAPH_WIRE_BYTES = 192 * 1_024;
32
- const repositoryGraphJobs = new RepositoryGraphJobs<ProjectRepositoryGraph>();
34
+ const repositoryGraphJobs = new RepositoryGraphJobs<ProjectRepositoryGraph>(Date.now,120_000,300_000,32,
35
+ new RepositoryGraphCache(()=>join(configDir(),"repository-graph-cache")));
33
36
 
34
37
  export async function syncProjectBinding(
35
38
  project: Project,
@@ -192,19 +195,19 @@ export async function projectRepositoryGraphs(
192
195
  const graphs = options.background
193
196
  ? repositories.map((binding) => repositoryGraphJobs.read(
194
197
  JSON.stringify([projectId, binding.repositoryId, binding.revision]),
195
- () => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000),
198
+ () => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000, binding.revision),
196
199
  options.priority ?? 0,
197
200
  ))
198
- : await Promise.all(repositories.map((binding) =>
199
- analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000).then((graph) => {
200
- repositoryGraphJobs.remember(JSON.stringify([projectId, binding.repositoryId, binding.revision]), graph);
201
- return graph;
202
- })));
201
+ : await Promise.all(repositories.map((binding) => repositoryGraphJobs.refresh(
202
+ JSON.stringify([projectId, binding.repositoryId, binding.revision]),
203
+ () => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000, binding.revision),
204
+ )));
203
205
  return boundRepositoryGraphs(graphs.filter((item): item is ProjectRepositoryGraph => item != null));
204
206
  }
205
207
 
206
208
  async function analyzeRepositoryGraph(
207
209
  client: EngineClientLike | undefined, projectId: string, repositoryId: string, timeoutMs: number,
210
+ sourceRevision?: string,
208
211
  ): Promise<ProjectRepositoryGraph | undefined> {
209
212
  // The daemon serves a socket serially. Analysis must not occupy the channel
210
213
  // used for policy, bindings and Backbone reads while a repository is scanned.
@@ -222,6 +225,7 @@ async function analyzeRepositoryGraph(
222
225
  return {
223
226
  projectId: graph.project_id, repositoryId: graph.repository_id,
224
227
  revision: graph.revision, weavatrixVersion: graph.weavatrix_version,
228
+ sourceRevision,
225
229
  analysisId: graph.analysis_id ?? undefined,
226
230
  analysisStatus: graph.analysis_status,
227
231
  architectureHypotheses: graph.architecture_hypotheses,
@@ -269,9 +273,10 @@ async function analyzeRepositoryGraph(
269
273
  function boundRepositoryGraphs(graphs: ProjectRepositoryGraph[]): ProjectRepositoryGraph[] {
270
274
  const result: ProjectRepositoryGraph[] = [];
271
275
  let remaining = MAX_REPOSITORY_GRAPH_WIRE_BYTES;
272
- for (const graph of graphs) {
276
+ for (const [index, graph] of graphs.entries()) {
273
277
  if (remaining < 1_024) break;
274
- const bounded = fitRepositoryGraph(graph, Math.min(remaining, MAX_SINGLE_GRAPH_WIRE_BYTES));
278
+ const fairShare = Math.floor(remaining / (graphs.length - index));
279
+ const bounded = fitRepositoryGraph(graph, Math.min(fairShare, MAX_SINGLE_GRAPH_WIRE_BYTES));
275
280
  if (!bounded) continue;
276
281
  result.push(bounded);
277
282
  remaining -= Buffer.byteLength(JSON.stringify(bounded), "utf8");
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, readdirSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ProjectRepositoryGraph } from '../../../../../../packages/protocol/schema';
5
+ import { fitRepositoryGraph } from './fit';
6
+ /** Private computer-side observations; no hand-authored architecture enters this cache. */
7
+ export class RepositoryGraphCache {
8
+ constructor(private readonly directory: string | (() => string), private readonly now: () => number = Date.now) { }
9
+ read(key: string): {
10
+ value: ProjectRepositoryGraph;
11
+ savedAt: number;
12
+ } | undefined {
13
+ try {
14
+ const path = this.path(key);
15
+ if (statSync(path).size > 256 * 1024)
16
+ return undefined;
17
+ const record = JSON.parse(readFileSync(path, 'utf8'));
18
+ const graph = ProjectRepositoryGraph.safeParse(record.value);
19
+ if (record.key !== key || !Number.isFinite(record.savedAt) || !graph.success
20
+ || !['COMPLETE', 'INCOMPLETE'].includes(graph.data.analysisStatus ?? ''))
21
+ return undefined;
22
+ return { value: graph.data, savedAt: record.savedAt };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ write(key: string, value: ProjectRepositoryGraph): void {
29
+ if (!['COMPLETE', 'INCOMPLETE'].includes(value.analysisStatus ?? ''))
30
+ return;
31
+ const bounded = fitRepositoryGraph(value, 192 * 1024);
32
+ if (!bounded) return;
33
+ const json = JSON.stringify({ key, value: bounded, savedAt: this.now() });
34
+ if (Buffer.byteLength(json) > 256 * 1024)
35
+ return;
36
+ try {
37
+ const directory = typeof this.directory === 'function' ? this.directory() : this.directory;
38
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
39
+ const path = this.path(key), temporary = path + '.tmp';
40
+ writeFileSync(temporary, json, { mode: 0o600 });
41
+ renameSync(temporary, path);
42
+ const files = readdirSync(directory).filter(name => name.endsWith('.json'))
43
+ .map(name => ({ path: join(directory, name), mtime: statSync(join(directory, name)).mtimeMs }))
44
+ .sort((a, b) => b.mtime - a.mtime);
45
+ for (const file of files.slice(128))
46
+ unlinkSync(file.path);
47
+ }
48
+ catch { /* The live graph remains available when disk storage is unavailable. */ }
49
+ }
50
+ private path(key: string): string {
51
+ const directory = typeof this.directory === 'function' ? this.directory() : this.directory;
52
+ return join(directory, createHash('sha256').update(key).digest('hex') + '.json');
53
+ }
54
+ }
@@ -4,6 +4,7 @@ export class RepositoryGraphJobs<T> {
4
4
  value?: T;
5
5
  retryAt: number;
6
6
  pending: boolean;
7
+ listeners: Array<(value: T | undefined) => void>;
7
8
  }>();
8
9
  private readonly queue: Array<{
9
10
  key: string; run: () => Promise<T | undefined>; priority: number;
@@ -15,25 +16,61 @@ export class RepositoryGraphJobs<T> {
15
16
  private readonly successMs = 120_000,
16
17
  private readonly failureMs = 300_000,
17
18
  private readonly capacity = 32,
19
+ private readonly storage?: {read(key:string):{value:T;savedAt:number}|undefined;write(key:string,value:T):void},
18
20
  ) {}
19
21
 
20
- read(key: string, run: () => Promise<T | undefined>, priority = 0): T | undefined {
22
+ read(key: string, run: () => Promise<T | undefined>, priority = 0, force = false): T | undefined {
23
+ if (!this.entries.has(key)) {
24
+ const saved = this.storage?.read(key);
25
+ if (saved) this.entries.set(key, {value:saved.value,retryAt:saved.savedAt+this.successMs,
26
+ pending:false,listeners:[]});
27
+ }
21
28
  const previous = this.entries.get(key);
22
29
  if (previous?.pending) return previous.value;
23
- if (previous && this.now() < previous.retryAt) return previous.value;
24
- if (this.queue.length >= this.capacity) return undefined;
30
+ if (previous && !force && this.now() < previous.retryAt) return previous.value;
31
+ if (!this.admit(priority)) return undefined;
25
32
  if (!previous && this.entries.size >= this.capacity * 2) {
26
33
  const oldest = [...this.entries].find(([, entry]) => !entry.pending)?.[0];
27
34
  if (!oldest) return undefined;
28
35
  this.entries.delete(oldest);
29
36
  }
30
- this.entries.set(key, { value: previous?.value, retryAt: 0, pending: true });
37
+ this.entries.set(key, { value: previous?.value, retryAt: 0, pending: true, listeners: [] });
31
38
  const before = this.queue.findIndex((job) => job.priority < priority);
32
39
  this.queue.splice(before < 0 ? this.queue.length : before, 0, { key, run, priority });
33
40
  void this.drain();
34
41
  return previous?.value;
35
42
  }
36
43
 
44
+ /** A requested build joins the same revision's automatic job and raises its queue priority. */
45
+ refresh(key: string, run: () => Promise<T | undefined>, priority = Number.MAX_SAFE_INTEGER): Promise<T | undefined> {
46
+ if (!this.entries.get(key)?.pending) this.read(key, run, priority, true);
47
+ const entry = this.entries.get(key);
48
+ if (!entry?.pending) return Promise.resolve(undefined);
49
+ const index = this.queue.findIndex(job => job.key === key);
50
+ if (index >= 0) {
51
+ const job = this.queue.splice(index, 1)[0]!;
52
+ job.priority = Math.max(priority, job.priority);
53
+ const before = this.queue.findIndex(other => other.priority < job.priority);
54
+ this.queue.splice(before < 0 ? this.queue.length : before, 0, job);
55
+ }
56
+ return new Promise(resolve => entry.listeners.push(resolve));
57
+ }
58
+
59
+ private admit(priority: number): boolean {
60
+ if (this.queue.length < this.capacity) return true;
61
+ const last = this.queue[this.queue.length - 1];
62
+ if (!last || last.priority >= priority) return false;
63
+ this.queue.pop();
64
+ const entry = this.entries.get(last.key);
65
+ if (entry) {
66
+ entry.pending = false;
67
+ entry.retryAt = this.now() + this.failureMs;
68
+ for (const listener of entry.listeners) listener(undefined);
69
+ entry.listeners = [];
70
+ }
71
+ return true;
72
+ }
73
+
37
74
  /** An explicit refresh supersedes an older queued or running observation. */
38
75
  remember(key: string, value: T | undefined): void {
39
76
  for (let index = this.queue.length - 1; index >= 0; index--) {
@@ -44,7 +81,9 @@ export class RepositoryGraphJobs<T> {
44
81
  if (!oldest) return;
45
82
  this.entries.delete(oldest);
46
83
  }
47
- this.entries.set(key, { value, pending: false,
84
+ for (const listener of this.entries.get(key)?.listeners ?? []) listener(value);
85
+ if (value !== undefined) this.storage?.write(key, value);
86
+ this.entries.set(key, { value, pending: false, listeners: [],
48
87
  retryAt: this.now() + (value === undefined ? this.failureMs : this.successMs) });
49
88
  }
50
89
 
@@ -58,11 +97,14 @@ export class RepositoryGraphJobs<T> {
58
97
  let value: T | undefined;
59
98
  try { value = await job.run(); } catch { /* Retry after backoff. */ }
60
99
  if (this.entries.get(job.key) !== entry) continue;
100
+ if (value !== undefined) this.storage?.write(job.key, value);
61
101
  this.entries.set(job.key, {
62
102
  value,
63
103
  retryAt: this.now() + (value === undefined ? this.failureMs : this.successMs),
64
104
  pending: false,
105
+ listeners: [],
65
106
  });
107
+ for (const listener of entry?.listeners ?? []) listener(value);
66
108
  }
67
109
  } finally {
68
110
  this.running = false;
@@ -0,0 +1,12 @@
1
+ # Task key persistence
2
+
3
+ `persistedSessionKey` preserves one stable task encryption key across concurrent
4
+ MCP processes. New keys use an exclusive computer-local creation lock, re-read
5
+ the current dictionary under that lock, and atomically replace the protected
6
+ snapshot. Existing keys remain readable without waiting for another creator.
7
+ An unreadable dictionary is never replaced with an empty one. Locks left by
8
+ terminated owners can be recovered; active owners are never displaced.
9
+
10
+ The public transport entry remains `../session-keys.ts`. Tests exercise actual
11
+ concurrent processes and the durable dictionary without logging key material.
12
+ This module uses the repository MIT License.
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { generateTransferKey } from "../../../../../packages/core/crypto";
5
+ type SessionKeys = Record<string, string>;
6
+ const pause = new Int32Array(new SharedArrayBuffer(4));
7
+ export function loadSessionKeys(path: string): SessionKeys {
8
+ if (!existsSync(path))
9
+ return {};
10
+ if (statSync(path).size > 8 * 1024 * 1024)
11
+ throw Error("Task key store exceeds its private storage limit");
12
+ let value: unknown;
13
+ try {
14
+ value = JSON.parse(readFileSync(path, "utf8"));
15
+ }
16
+ catch {
17
+ throw Error("Task key store is unreadable");
18
+ }
19
+ if (!value || typeof value !== "object" || Array.isArray(value))
20
+ throw Error("Task key store is unreadable");
21
+ return Object.fromEntries(Object.entries(value).filter((entry): entry is [
22
+ string,
23
+ string
24
+ ] => typeof entry[1] === "string" && entry[1].length >= 43 && entry[1].length <= 44));
25
+ }
26
+ /** Creation is serialized across local MCP processes; reads use atomic snapshots. */
27
+ export function persistedSessionKey(path: string, sessionId: string, waitMs = 5000): string {
28
+ const existing = loadSessionKeys(path);
29
+ if (Object.hasOwn(existing, sessionId))
30
+ return existing[sessionId]!;
31
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
32
+ const lock = path + ".lock", deadline = Date.now() + waitMs;
33
+ const owner = acquire(lock, deadline);
34
+ try {
35
+ const keys = loadSessionKeys(path);
36
+ if (Object.hasOwn(keys, sessionId))
37
+ return keys[sessionId]!;
38
+ const key = generateTransferKey();
39
+ const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
40
+ try {
41
+ writeFileSync(temporary, JSON.stringify({ ...keys, [sessionId]: key }) + "\n", { mode: 0o600 });
42
+ renameSync(temporary, path);
43
+ }
44
+ finally {
45
+ try {
46
+ unlinkSync(temporary);
47
+ }
48
+ catch { }
49
+ }
50
+ return key;
51
+ }
52
+ finally {
53
+ unlinkSync(owner);
54
+ rmdirSync(lock);
55
+ }
56
+ }
57
+ function acquire(path: string, deadline: number): string {
58
+ for (;;) {
59
+ try {
60
+ mkdirSync(path, { mode: 0o700 });
61
+ const owner = path + "/owner-" + process.pid + "-" + randomUUID();
62
+ writeFileSync(owner, "", { flag: "wx", mode: 0o600 });
63
+ return owner;
64
+ }
65
+ catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST")
67
+ throw error;
68
+ removeDeadOwner(path);
69
+ if (Date.now() >= deadline)
70
+ throw Error("Task key creation is busy; retry later");
71
+ Atomics.wait(pause, 0, 0, 10);
72
+ }
73
+ }
74
+ }
75
+ function removeDeadOwner(path: string): void {
76
+ try {
77
+ const entries = readdirSync(path);
78
+ if (entries.length === 0) {
79
+ if (Date.now() - statSync(path).mtimeMs > 30000)
80
+ rmdirSync(path);
81
+ return;
82
+ }
83
+ if (entries.length !== 1)
84
+ return;
85
+ const owner = entries[0]!, match = /^owner-(\d+)-/.exec(owner);
86
+ if (!match)
87
+ return;
88
+ const pid = Number(match[1]);
89
+ if (!Number.isSafeInteger(pid) || pid <= 0)
90
+ return;
91
+ try {
92
+ process.kill(pid, 0);
93
+ }
94
+ catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") {
96
+ unlinkSync(path + "/" + owner);
97
+ rmdirSync(path);
98
+ }
99
+ }
100
+ }
101
+ catch { /* Another creator may already have released the lock. */ }
102
+ }
@@ -1,6 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { generateTransferKey } from "../../../../packages/core/crypto";
3
+ import { loadSessionKeys, persistedSessionKey } from "./session-key-store";
4
4
  import { RelayClient, type SendOptions } from "../../../../packages/core/relay-client";
5
5
  import type {
6
6
  MeshClaimReleaseResult,
@@ -13,41 +13,17 @@ import type {
13
13
  } from "../../../../packages/protocol/schema";
14
14
  import { configDir } from "../config";
15
15
 
16
- type SessionKeys = Record<string, string>;
17
-
18
16
  export function sessionKeysPath(): string {
19
17
  return join(configDir(), "session-keys.json");
20
18
  }
21
19
 
22
- function load(): SessionKeys {
23
- try {
24
- const value = JSON.parse(readFileSync(sessionKeysPath(), "utf8")) as unknown;
25
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
26
- return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] =>
27
- typeof entry[1] === "string" && entry[1].length >= 43 && entry[1].length <= 44));
28
- } catch {
29
- return {};
30
- }
31
- }
32
-
33
- function save(keys: SessionKeys): void {
34
- mkdirSync(configDir(), { recursive: true });
35
- writeFileSync(sessionKeysPath(), `${JSON.stringify(keys, null, 2)}\n`, { mode: 0o600 });
36
- chmodSync(sessionKeysPath(), 0o600);
37
- }
38
-
39
20
  export function sessionKey(sessionId: string): string {
40
- const keys = load();
41
- if (keys[sessionId]) return keys[sessionId]!;
42
- const key = generateTransferKey();
43
- keys[sessionId] = key;
44
- save(keys);
45
- return key;
21
+ return persistedSessionKey(sessionKeysPath(), sessionId);
46
22
  }
47
23
 
48
24
  export function primeSessionKeys(client: RelayClient): void {
49
25
  if (!existsSync(sessionKeysPath())) return;
50
- for (const [sessionId, key] of Object.entries(load())) client.setSessionKey(sessionId, key);
26
+ for (const [sessionId, key] of Object.entries(loadSessionKeys(sessionKeysPath()))) client.setSessionKey(sessionId, key);
51
27
  }
52
28
 
53
29
  /**
@@ -102,6 +78,6 @@ async function sendScopedPayload(input: {
102
78
  key,
103
79
  purpose,
104
80
  createdAt: Date.now(),
105
- }, to, { ttlMs: options.ttlMs });
81
+ }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey });
106
82
  await client.sendSession(payload, sessionId, to, options);
107
83
  }
@@ -56,7 +56,7 @@ export async function publishSessionEvents(
56
56
  sessionId: string,
57
57
  status?: SessionsStatus,
58
58
  threadId?: string,
59
- history?: { history?: boolean; historyCursor?: string },
59
+ history?: { history?: boolean; historyCursor?: string; peerPublicKey?: string },
60
60
  ): Promise<boolean> {
61
61
  const session = resolveSession(client, sessionId, status);
62
62
  if (!session) return false;
@@ -66,6 +66,7 @@ export async function publishSessionEvents(
66
66
  await sendSessionPayload(client, activityWithImages(activity), sessionId, "phone", {
67
67
  ttlMs: INTERVAL_MS * 24,
68
68
  reliable: false,
69
+ peerPublicKey: history?.peerPublicKey,
69
70
  });
70
71
  return true;
71
72
  }
@@ -35,6 +35,7 @@ import { handleTaskCreate } from "./task-create";
35
35
  import { handleUserMessage } from "./user-message";
36
36
  import { publishSessionEvents } from "./catalog";
37
37
  import { publishSessionImage } from '../../session-images';
38
+ import { publishTaskWorkspace } from '../../task-files/relay';
38
39
  import { publishHistoryPage } from "../history/pages";
39
40
  import { requestProjectGraphRefresh } from "../graph-refresh";
40
41
  import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
@@ -83,9 +84,9 @@ export async function handleMonitorMessage(
83
84
  void publish().catch(() => {});
84
85
  return true;
85
86
  } else if (payload.type === "session.subscribe") {
86
- return handleInboundSubscription(client, payload, subscriptions, publish);
87
- } else if (payload.type === "session.events" || payload.type === "session.image.query") {
88
- return handleSessionRead(client, payload);
87
+ return handleInboundSubscription(client, payload, subscriptions, publish, input.peerPublicKey);
88
+ } else if (payload.type === "session.events" || payload.type === "session.image.query" || payload.type === "session.workspace.query") {
89
+ return handleSessionRead(client, payload, input.peerPublicKey);
89
90
  } else if (payload.type === "sessions.refresh") {
90
91
  return handleInboundRefresh(client, payload, publish);
91
92
  } else if (payload.type === "sessions.history.query") {
@@ -266,9 +267,10 @@ async function handleInboundTaskCreate(
266
267
  function handleInboundSubscription(
267
268
  client: RelayClient, payload: Extract<Payload, { type: "session.subscribe" }>,
268
269
  subscriptions: Set<string>, publish: (forceHistory?: boolean) => Promise<void>,
270
+ peerPublicKey?: string,
269
271
  ): boolean {
270
272
  // The transcript goes first, before a potentially slow catalog rescan.
271
- void publishSessionEvents(client, payload.sessionId).catch(() => false);
273
+ void publishSessionEvents(client, payload.sessionId, undefined, undefined, { peerPublicKey }).catch(() => false);
272
274
  if (handleSubscription(subscriptions, payload)) {
273
275
  void publish().catch(() => {});
274
276
  }
@@ -276,8 +278,11 @@ function handleInboundSubscription(
276
278
  }
277
279
 
278
280
  async function handleSessionRead(client: RelayClient,
279
- payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }>): Promise<boolean> {
280
- if (payload.type === 'session.image.query') await publishSessionImage(client, payload);
281
- else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId, payload).catch(() => false);
281
+ payload: Extract<Payload, { type: 'session.events' | 'session.image.query' | 'session.workspace.query' }>,
282
+ peerPublicKey?: string): Promise<boolean> {
283
+ if (payload.type === 'session.workspace.query') await publishTaskWorkspace(client, payload, peerPublicKey);
284
+ else if (payload.type === 'session.image.query') await publishSessionImage(client, payload, peerPublicKey);
285
+ else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId,
286
+ { ...payload, peerPublicKey }).catch(() => false);
282
287
  return true;
283
288
  }
@@ -1,5 +1,8 @@
1
1
  # Mesh publication
2
2
 
3
+ Ready enriched Projects are delivered independently. A slow binding restoration
4
+ or unavailable Project cannot delay a completed report from another Project.
5
+
3
6
  `index.ts` owns one background Mesh publication per monitor transport. Project
4
7
  projection yields to live decisions and uses the observed native catalog. Plain
5
8
  snapshots, policy reports, and Engine enrichment remain separate from fresh chat
@@ -0,0 +1,11 @@
1
+ import type { MeshSnapshot } from '../../../../../packages/protocol/schema';
2
+ import { enrichMeshSnapshot } from '../../mesh/runtime/snapshot/enriched';
3
+ /** Publish ready Projects independently so one slow binding cannot hold other reports. */
4
+ export async function publishEnrichedMeshes(snapshots: MeshSnapshot[], send: (snapshot: MeshSnapshot) => Promise<void>, enrich: typeof enrichMeshSnapshot = enrichMeshSnapshot): Promise<void> {
5
+ await Promise.all(snapshots.map(async (snapshot) => {
6
+ try {
7
+ await send(await enrich(snapshot));
8
+ }
9
+ catch { /* This Project is retried on the next publication tick. */ }
10
+ }));
11
+ }
@@ -1,7 +1,8 @@
1
1
  import type { RelayClient } from "../../../../../packages/core/relay-client";
2
2
  import type { SessionsStatus } from "../../../../../packages/protocol/schema";
3
3
  import { localMeshStore } from "../../mesh/local-remote/local";
4
- import { meshSnapshotsAsync, meshSnapshotsWithEngine } from "../../mesh/runtime";
4
+ import { meshSnapshotsAsync } from "../../mesh/runtime";
5
+ import { publishEnrichedMeshes } from "./enrichment";
5
6
  import { deriveObservedClaims } from "../../mesh/observed/claims";
6
7
  import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
7
8
  import { publishProjectPolicyStatuses } from "../../project-policy/runtime";
@@ -29,11 +30,10 @@ export function publishMonitorMeshes(client: RelayClient, status: SessionsStatus
29
30
  }
30
31
  if (!token.active || !client.isConnected) return;
31
32
  await publishProjectPolicyStatuses(client, snapshots.map(mesh => mesh.projectId)).catch(() => {});
32
- const enriched = await meshSnapshotsWithEngine(snapshots);
33
- for (const snapshot of enriched) {
33
+ await publishEnrichedMeshes(snapshots, async snapshot => {
34
34
  if (!token.active || !client.isConnected) return;
35
35
  await sendMeshPayload(client, snapshot, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false }).catch(() => {});
36
- }
36
+ });
37
37
  })();
38
38
  void work.catch(() => {}).finally(() => { if (pending.get(client) === token) pending.delete(client); });
39
39
  }
@@ -21,3 +21,5 @@ Engine binding recovery waits for the first connected catalog publication before
21
21
  Codex discovery shares repeated checkout verification only within one synchronous scan. The next scan verifies repository identity again; no long-lived checkout cache can cross a repository change. This keeps startup catalog delivery ahead of repeated Git subprocesses.
22
22
 
23
23
  Subscribed transcript heads use a separate, single-flight transfer loop. A stalled head never occupies the current catalog publication slot; stopping the monitor cancels further queued reads. The iPhone keeps its historical backfill independent of both loops.
24
+
25
+ Authenticated subscription and transcript requests pass the verified requester key to the encrypted reply. A requested page does not wait for copies addressed to other paired controllers. Shared catalogue and chat updates remain available to passive controllers.
@@ -17,3 +17,9 @@ Native image identities remain readable after the chat head advances. Codex
17
17
  attachments are located by timestamp and content block together, so a caption
18
18
  with the same timestamp cannot hide the picture. Original bytes are delivered in
19
19
  bounded encrypted chunks and the Apple client retains them for offline use.
20
+
21
+ An authenticated image query returns its key grant and chunk only to the requesting controller. Shared chat publication still retains every authorized controller, including one receiving passively.
22
+
23
+ Saved native picture identities remain addressable after newer transcript pages append. Lookup walks bounded windows toward the original row, verifies the exact conversation and block identity, and stops at the file start or a non-advancing cursor. There is no page-count expiry for a previously advertised attachment.
24
+
25
+ Workspace screenshots in older replies resolve the original native row and its exact advertised image identity before applying the existing Task, Execution and workspace access checks. A request never supplies a filesystem path, and looking up an older reply does not widen access outside its authorized workspace.
@@ -22,7 +22,8 @@ export function readSessionImage(query: SessionImageQuery, session: SessionInfo,
22
22
  return sessionImageChunk(session, activity, query.imageId, query.offset, query.historyCursor);
23
23
  }
24
24
 
25
- export async function publishSessionImage(client: RelayClient, query: SessionImageQuery): Promise<boolean> {
25
+ export async function publishSessionImage(client: RelayClient, query: SessionImageQuery,
26
+ peerPublicKey?: string): Promise<boolean> {
26
27
  const session = publishedSession(client, query.sessionId)
27
28
  ?? scanSessions().sessions.find(s => s.sessionId === query.sessionId)
28
29
  ?? scanSessionHistory().find(s => s.sessionId === query.sessionId);
@@ -33,6 +34,6 @@ export async function publishSessionImage(client: RelayClient, query: SessionIma
33
34
  requestId: query.requestId, imageId: query.imageId, offset: query.offset,
34
35
  ...(chunk ? { mimeType: chunk.mime_type, totalBytes: chunk.total_bytes,
35
36
  dataBase64: chunk.data_base64 } : { unavailable: true }) }), query.sessionId,
36
- 'phone', { ttlMs: 20_000, reliable: false });
37
+ 'phone', { ttlMs: 20_000, reliable: false, peerPublicKey });
37
38
  return chunk !== undefined;
38
39
  }
@@ -0,0 +1,16 @@
1
+ # Task files
2
+
3
+ `readTaskWorkspace` reads a directory page or a bounded UTF-8 file from the exact
4
+ native execution workspace. It accepts relative paths, resolves symlinks inside
5
+ that workspace, and rejects credentials, traversal and external paths. The
6
+ viewer performs no writes, shell operations, model calls or provider actions.
7
+
8
+ `publishTaskWorkspace` resolves the execution through the computer's published
9
+ catalog and returns an authenticated task-encrypted reply to the requesting
10
+ controller. The local Mac counterpart validates Project, Task and Execution
11
+ identity before using the same reader. Directory pages contain at most 200
12
+ entries; file previews contain at most 128 KiB and explicitly mark truncation.
13
+
14
+ Module tests cover real files, nested directories, boundaries and pagination.
15
+
16
+ This module uses the repository MIT License.
@@ -0,0 +1,96 @@
1
+ import { isUtf8 } from 'node:buffer';
2
+ import { closeSync, lstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import type { SessionInfo } from '../../../../packages/protocol/schema';
5
+ export type TaskWorkspaceQuery = {
6
+ mode: 'directory' | 'file';
7
+ path: string;
8
+ offset?: number;
9
+ };
10
+ export type TaskWorkspaceEntry = {
11
+ path: string;
12
+ name: string;
13
+ kind: 'directory' | 'file';
14
+ size?: number;
15
+ };
16
+ export type TaskWorkspaceRead = {
17
+ path: string;
18
+ entries?: TaskWorkspaceEntry[];
19
+ text?: string;
20
+ truncated: boolean;
21
+ nextOffset?: number;
22
+ };
23
+ const protectedPath = (path: string) => path.split('/').some(part => /^\.env(?:\.|$)/i.test(part)
24
+ || ['.git', '.ssh', '.aws', '.private_keys', '.codex', '.claude', '.cursor', '.granttap', '.nodvox'].includes(part)
25
+ || /\.(?:p8|pem|key)$/i.test(part));
26
+ function admittedPath(root: string, path: string): string | undefined {
27
+ if (path.includes('\0') || path.includes('\\') || isAbsolute(path) || /^[A-Za-z]:/.test(path)
28
+ || path.split('/').includes('..') || protectedPath(path))
29
+ return undefined;
30
+ try {
31
+ const target = realpathSync(resolve(root, path));
32
+ const within = relative(root, target);
33
+ return within === '..' || within.startsWith('..' + sep) || isAbsolute(within)
34
+ || protectedPath(within.split(sep).join('/')) ? undefined : target;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** Only the exact known native execution's workspace is readable. */
41
+ export function readTaskWorkspace(session: SessionInfo, query: TaskWorkspaceQuery): TaskWorkspaceRead | undefined {
42
+ const path = session.worktree ?? session.cwd;
43
+ if (!path || !['directory', 'file'].includes(query.mode))
44
+ return undefined;
45
+ try {
46
+ const root = realpathSync(path), target = admittedPath(root, query.path);
47
+ if (!target || !statSync(root).isDirectory())
48
+ return undefined;
49
+ const offset = query.offset ?? 0;
50
+ if (!Number.isSafeInteger(offset) || offset < 0)
51
+ return undefined;
52
+ if (query.mode === 'file')
53
+ return file(target, query.path, offset);
54
+ if (!statSync(target).isDirectory())
55
+ return undefined;
56
+ const entries = readdirSync(target).flatMap(name => {
57
+ const path = query.path ? query.path + '/' + name : name;
58
+ const full = admittedPath(root, path);
59
+ if (!full)
60
+ return [];
61
+ const info = statSync(full);
62
+ if (!info.isDirectory() && !info.isFile())
63
+ return [];
64
+ return [{ path, name, kind: info.isDirectory() ? 'directory' as const : 'file' as const,
65
+ ...(info.isFile() ? { size: info.size } : {}) }];
66
+ }).sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1);
67
+ const next = offset + 200, truncated = next < entries.length;
68
+ return { path: query.path, entries: entries.slice(offset, next), truncated,
69
+ ...(truncated ? { nextOffset: next } : {}) };
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ function file(target: string, path: string, offset: number): TaskWorkspaceRead | undefined {
76
+ const info = lstatSync(target);
77
+ if (!info.isFile() || offset > info.size)
78
+ return undefined;
79
+ const fd = openSync(target, 'r');
80
+ try {
81
+ const bytes = Buffer.alloc(Math.min(info.size - offset, 128 * 1024));
82
+ const count = readSync(fd, bytes, 0, bytes.length, offset);
83
+ let read = bytes.subarray(0, count);
84
+ // A bounded preview may end inside a UTF8 character; invalid interior bytes remain refused.
85
+ if (info.size > offset + count)
86
+ for (let trim = 1; trim <= 3 && !isUtf8(read); trim++)
87
+ read = bytes.subarray(0, count - trim);
88
+ if (!isUtf8(read) || /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(read.toString('utf8')))
89
+ return undefined;
90
+ const next = offset + read.length, truncated = info.size > next;
91
+ return { path, text: read.toString('utf8'), truncated, ...(truncated ? { nextOffset: next } : {}) };
92
+ }
93
+ finally {
94
+ closeSync(fd);
95
+ }
96
+ }
@@ -0,0 +1,14 @@
1
+ import type { RelayClient } from '../../../../packages/core/relay-client';
2
+ import { SessionWorkspaceResult, type SessionWorkspaceQuery } from '../../../../packages/protocol/schema';
3
+ import { publishedSession } from '../session-catalog';
4
+ import { sendSessionPayload } from '../host/session-keys';
5
+ import { readTaskWorkspace } from '.';
6
+ /** Session identity comes from the computer's admitted catalog, never from a requested root. */
7
+ export async function publishTaskWorkspace(client: RelayClient, query: SessionWorkspaceQuery, peerPublicKey?: string): Promise<boolean> {
8
+ const session = publishedSession(client, query.sessionId);
9
+ const result = session ? readTaskWorkspace(session, query) : undefined;
10
+ await sendSessionPayload(client, SessionWorkspaceResult.parse({ type: 'session.workspace.result',
11
+ sessionId: query.sessionId, requestId: query.requestId,
12
+ ...(result ?? { path: query.path, truncated: false, unavailable: true }), createdAt: Date.now() }), query.sessionId, 'phone', { peerPublicKey, ttlMs: 30000, reliable: false });
13
+ return result !== undefined;
14
+ }
@@ -0,0 +1,43 @@
1
+ import type { ActivityEntry, SessionInfo } from "../../../../../packages/protocol/schema";
2
+ import { appendCodexActivity } from "../../sessions/scan/codex/activity";
3
+ import { codexLogPathForSession } from "../../sessions/scan/codex/shared";
4
+ import { appendClaudeActivity, claudeLogPath } from "../../sessions/scan/claude";
5
+ import { safeParse, ts } from "../../sessions/support/common";
6
+ import { previousNativeLines } from "../native-lines";
7
+
8
+ /** Recover only the exact previously advertised row in this native conversation. */
9
+ export function nativeTranscriptEntry(session: SessionInfo, entryId: string,
10
+ cursor?: string): ActivityEntry | undefined {
11
+ if (!entryId.startsWith(`${session.sessionId}:`)) return undefined;
12
+ const path = session.agent === "codex" ? codexLogPathForSession(session.sessionId, session.startedAt)
13
+ : session.agent === "claude" ? claudeLogPath(session.sessionId) : undefined;
14
+ if (!path) return undefined;
15
+ const stamp = session.agent === "codex" ? Number(entryId.slice(session.sessionId.length + 1).split(":")[0]) : undefined;
16
+ if (stamp != null && !Number.isFinite(stamp)) return undefined;
17
+ let pageCursor = cursor;
18
+ try {
19
+ while (true) {
20
+ const window = previousNativeLines(path, session.sessionId, pageCursor);
21
+ if (!window) return undefined;
22
+ let earliest = Infinity;
23
+ for (const line of window.lines) {
24
+ const row = safeParse(line.text), at = ts(row?.timestamp);
25
+ if (at) earliest = Math.min(earliest, at);
26
+ if (stamp != null && at !== stamp) continue;
27
+ const out: ActivityEntry[] = [], seen = new Set<string>(), observations = new Map();
28
+ if (session.agent === "codex") {
29
+ appendCodexActivity({ out, seen, session, lines: [line.text], observations,
30
+ changes: new Map(), details: new Map(), fullText: true });
31
+ } else {
32
+ appendClaudeActivity({ out, seen, session, lines: [line.text], observations, fullText: true });
33
+ }
34
+ const found = out.find(entry => entry.id === entryId);
35
+ if (found) return found;
36
+ }
37
+ if (window.start === 0 || (stamp != null && earliest < stamp)) return undefined;
38
+ const next = window.lines[0]?.offset ?? window.start;
39
+ if (next >= window.end) return undefined;
40
+ pageCursor = window.cursorAt(next);
41
+ }
42
+ } catch { return undefined; }
43
+ }
@@ -22,7 +22,8 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
22
22
  if (!path || !['claude', 'codex'].includes(session.agent)) return undefined;
23
23
  try {
24
24
  let pageCursor = cursor;
25
- for (let page = 0; page < 32; page++) {
25
+ // Old advertised pictures remain readable as the conversation grows.
26
+ while (true) {
26
27
  const window = previousNativeLines(path, session.sessionId, pageCursor);
27
28
  if (!window) return undefined;
28
29
  const lines = window.lines.map(line => line.text);
@@ -49,7 +50,9 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
49
50
  }
50
51
  }
51
52
  if (window.start === 0) break;
52
- pageCursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
53
+ const next = window.lines[0]?.offset ?? window.start;
54
+ if (next >= window.end) break;
55
+ pageCursor = window.cursorAt(next);
53
56
  }
54
57
  } catch { return undefined; }
55
58
  return undefined;
@@ -11,7 +11,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
11
11
  if (!path || !Number.isFinite(stamp) || !Number.isSafeInteger(blockIndex) || blockIndex < 0) return undefined;
12
12
  let cursor: string | undefined;
13
13
  try {
14
- for (let page = 0; page < 32; page++) {
14
+ // A saved attachment does not expire when newer transcript windows append.
15
+ // Each window stays bounded and its cursor must move toward the file start.
16
+ while (true) {
15
17
  const window = previousNativeLines(path, sessionId, cursor);
16
18
  if (!window) return undefined;
17
19
  let earliest = Infinity;
@@ -24,7 +26,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
24
26
  && p.content[blockIndex]?.type === "input_image") return [line.text];
25
27
  }
26
28
  if (window.start === 0 || earliest < stamp) return undefined;
27
- cursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
29
+ const next = window.lines[0]?.offset ?? window.start;
30
+ if (next >= window.end) return undefined;
31
+ cursor = window.cursorAt(next);
28
32
  }
29
33
  } catch { return undefined; }
30
34
  return undefined;
@@ -8,6 +8,7 @@ import { recordedToolDetails } from "./tool-details";
8
8
  import { completedTurnChanges } from "./turn-changes";
9
9
  export { nativeImageLines } from "./images";
10
10
  export { nativeTranscriptImage } from './images/native';
11
+ export { nativeTranscriptEntry } from './images/advertised-entry';
11
12
 
12
13
  function boundedEntry(entry: ActivityEntry): ActivityEntry {
13
14
  const result = { ...entry };
@@ -18,7 +18,7 @@ export const DESKTOP_OPERATIONS = new Set([
18
18
  "project.list_bindings", "policy.get", "policy.coverage",
19
19
  "graph.get_backbone", "memory.history", "invocation.history",
20
20
  "desktop.project", "desktop.mesh_snapshot", "desktop.workspace", "desktop.task_activity",
21
- "desktop.machine_load", "desktop.capability_usage", "desktop.task_image",
21
+ "desktop.machine_load", "desktop.capability_usage", "desktop.task_image", "desktop.task_workspace",
22
22
  "desktop.installed_skills", "desktop.policy_status", "desktop.policy_set", "desktop.project_auto_accept",
23
23
  "desktop.mesh_snapshots",
24
24
  "desktop.task_send",
@@ -1,7 +1,7 @@
1
1
  import { artifactImages, artifactImageChunk } from "./artifacts";
2
2
  import { desktopTaskActivity } from "../task-activity";
3
3
  import type { ActivityEntry, SessionInfo, SessionActivity } from '../../../../../packages/protocol/schema';
4
- import { nativeTranscriptImage } from '../../../../bridge/src/transcript-history';
4
+ import { nativeTranscriptImage, nativeTranscriptEntry } from '../../../../bridge/src/transcript-history';
5
5
  import { readStoreState } from '../../../../bridge/src/mesh/store/state';
6
6
  import { configDir } from '../../../../bridge/src/config';
7
7
  import { join } from 'node:path';
@@ -10,16 +10,25 @@ export function transcriptImages(entry: ActivityEntry) {
10
10
  return artifactImages(entry).map(({ id, name, markdown }) => ({ id, name, markdown }));
11
11
  }
12
12
 
13
+ function sessionArtifact(session: SessionInfo, activity: { entries: Array<Parameters<typeof artifactImages>[0]> },
14
+ imageId: string, cursor?: string) {
15
+ const current = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
16
+ if (current) return current;
17
+ const entryId = imageId.match(/^(.*):image:[a-f0-9]{24}$/)?.[1];
18
+ const saved = entryId ? nativeTranscriptEntry(session, entryId, cursor) : undefined;
19
+ return saved ? artifactImages(saved).find(image => image.id === imageId) : undefined;
20
+ }
21
+
13
22
  export function sessionImageChunk(session: SessionInfo, activity: SessionActivity,
14
23
  imageId: string, offset: number, cursor?: string, storePath?: string) {
15
24
  if (activity.sessionId !== session.sessionId) return undefined;
16
25
  const native = activity.entries.some(entry => entry.id === imageId && entry.kind === 'user'
17
26
  && entry.attachments?.includes('Image'));
18
- if (native || imageId.startsWith(`${session.sessionId}:`)) {
27
+ if (!imageId.includes(':image:') && (native || imageId.startsWith(`${session.sessionId}:`))) {
19
28
  const picture = nativeTranscriptImage(session, imageId, offset, cursor);
20
29
  if (picture) return picture;
21
30
  }
22
- const artifact = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
31
+ const artifact = sessionArtifact(session, activity, imageId, cursor);
23
32
  if (!artifact) return undefined;
24
33
  const loaded = readStoreState(storePath ?? join(configDir(), 'project-mesh.json'));
25
34
  if (loaded.status !== 'ok') return undefined;
@@ -46,7 +55,11 @@ export function desktopTaskImage(input: unknown, storePath?: string,
46
55
  const activity = cachedActivity ?? desktopTaskActivity(query, storePath);
47
56
  if (!activity?.session_id || !activity.agent
48
57
  || activity.project_id !== query.project_id || activity.task_id !== query.task_id) return undefined;
49
- const artifact = activity.entries.flatMap(artifactImages).find((image) => image.id === imageId);
58
+ const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
59
+ state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
60
+ const cursor = query.history_cursor;
61
+ if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
62
+ const artifact = sessionArtifact(session, activity, imageId, cursor as string | undefined);
50
63
  if (artifact) {
51
64
  const chunk = artifactImageChunk({ projectId: activity.project_id, taskId: activity.task_id,
52
65
  sessionId: activity.session_id, provider: activity.agent, image: artifact, offset, storePath });
@@ -55,10 +68,6 @@ export function desktopTaskImage(input: unknown, storePath?: string,
55
68
  if (!["codex", "claude"].includes(activity.agent)
56
69
  || !activity.entries.some((entry) => entry.id === imageId
57
70
  && entry.kind === "user" && entry.attachments?.includes("Image"))) return undefined;
58
- const cursor = query.history_cursor;
59
- if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
60
- const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
61
- state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
62
71
  const chunk = nativeTranscriptImage(session, imageId, offset, cursor as string | undefined);
63
72
  return chunk ? { operation: "desktop.task_image", image_id: imageId, ...chunk } : undefined;
64
73
  }
@@ -10,6 +10,7 @@ import type { DesktopTaskActivityRunner } from "../task-activity-runner";
10
10
  import type { EngineClient } from "../../../../bridge/src/engine/runtime/engine-client";
11
11
  import type { EngineOperation } from "../../../../bridge/src/engine/protocol/engine-protocol";
12
12
  import { desktopTaskSend } from "../delivery/task-send";
13
+ import { desktopTaskWorkspace } from "../workspace/task-files";
13
14
  import { desktopTaskHandoff } from "../delivery/task-handoff";
14
15
  import { desktopTaskCreate } from "../delivery/task-create";
15
16
  import { desktopMeshCreate } from "../delivery/mesh-create";
@@ -54,6 +55,7 @@ export function desktopReadOperation(input: {
54
55
  if (operation === "desktop.machine_load") return desktopMachineLoad();
55
56
  if (operation === "desktop.task_activity") return activity.read(queryInput, storePath);
56
57
  if (operation === "desktop.task_image") return activity.image(queryInput, storePath);
58
+ if (operation === "desktop.task_workspace") return Promise.resolve(desktopTaskWorkspace(queryInput, storePath));
57
59
  if (operation === "desktop.task_send") return desktopTaskSend(queryInput, storePath);
58
60
  if (operation === "desktop.task_handoff") return desktopTaskHandoff(queryInput, storePath);
59
61
  if (operation === "desktop.task_create") return desktopTaskCreate(queryInput, storePath);
@@ -0,0 +1,8 @@
1
+ # Task workspace reader
2
+
3
+ `desktopTaskWorkspace` admits a Project, Task and exact native Execution before
4
+ reading its recorded workspace through the shared `task-files` public entry.
5
+ It exposes paged directory listings and bounded UTF-8 previews, with no writes.
6
+ Tests live in `../models/tests/task-workspace.test.ts`.
7
+
8
+ This module uses the repository MIT License.
@@ -0,0 +1,30 @@
1
+ import { join } from 'node:path';
2
+ import { configDir } from '../../../../bridge/src/config';
3
+ import { readStoreState } from '../../../../bridge/src/mesh/store/state';
4
+ import { readTaskWorkspace } from '../../../../bridge/src/task-files';
5
+ import type { SessionInfo } from '../../../../../packages/protocol/schema';
6
+ /** Native desktop requests must match the Task's recorded execution exactly. */
7
+ export function desktopTaskWorkspace(input: unknown, storePath = join(configDir(), 'project-mesh.json')) {
8
+ if (!input || typeof input !== 'object' || Array.isArray(input))
9
+ return undefined;
10
+ const query = input as Record<string, unknown>;
11
+ const { project_id: projectId, task_id: taskId, session_id: sessionId, mode, path, offset } = query;
12
+ if (typeof projectId !== 'string' || typeof taskId !== 'string' || typeof sessionId !== 'string'
13
+ || !['directory', 'file'].includes(String(mode)) || typeof path !== 'string' || path.length > 4096
14
+ || offset !== undefined && (!Number.isSafeInteger(offset) || Number(offset) < 0))
15
+ return undefined;
16
+ const loaded = readStoreState(storePath);
17
+ if (loaded.status !== 'ok')
18
+ return undefined;
19
+ const tasks = loaded.state.tasks.filter(t => t.taskId === taskId);
20
+ if (tasks.length !== 1 || tasks[0]?.projectId !== projectId)
21
+ return undefined;
22
+ const executions = loaded.state.executions.filter(e => e.taskId === taskId && e.sessionId === sessionId);
23
+ if (executions.length !== 1)
24
+ return undefined;
25
+ const execution = executions[0]!;
26
+ const session: SessionInfo = { sessionId, agent: execution.provider, cwd: execution.workspace,
27
+ state: 'idle', startedAt: execution.startedAt, lastActivityAt: execution.updatedAt ?? execution.startedAt, tokensSession: 0, tokensLastTurn: 0 };
28
+ const result = readTaskWorkspace(session, { mode: mode as 'directory' | 'file', path, offset: offset as number | undefined });
29
+ return result ? { operation: 'desktop.task_workspace', ...result } : undefined;
30
+ }
@@ -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.54
13
+ npm install -g granttap-mcp@0.8.56
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.54\";\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.56\";\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.54";
10
+ const PACKAGE = "granttap-mcp@0.8.56";
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.54",
3
+ "version": "0.8.56",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,11 +11,13 @@ Tests live in `tests/`; they include an actual WebSocket server with the relay's
11
11
  rate limit. A socket write is transport evidence, never an approval receipt.
12
12
 
13
13
  MIT License; see the repository LICENSE.
14
- # Active controllers
14
+ # Controller delivery
15
15
 
16
- Authenticated incoming phone traffic marks that controller active for 90 seconds.
17
- Large replaceable encrypted task/project data targets recent controllers while
18
- catalogs, liveness, approval status and reliable messages retain every authorized
19
- recipient. Current catalogs and liveness pass queued bulk frames. A transport with no recent controller falls back to
20
- the complete authorized set for legacy clients and reconnection. Routing never
21
- deletes a pairing or replaces the controller authorization gate.
16
+ Catalogs, liveness and shared conversation updates reach every authorized
17
+ controller, including a phone receiving passively. Silence never removes access
18
+ to its local replica. Authenticated history, subscription and image requests
19
+ return their independent key grant and encrypted result only to the requesting
20
+ controller, with priority over queued periodic snapshots. Older callers without
21
+ a requester retain the complete authorized recipient set. Every explicit target
22
+ passes the pairing and controller authorization checks. Routing never deletes a
23
+ pairing or changes approval policy.
@@ -3,7 +3,6 @@ import type { PeerConfig, SendOptions } from "../relay-client-types";
3
3
  import type { Payload, Role } from "../../protocol/schema";
4
4
  import { peerPublicKeys } from "../crypto";
5
5
  import { sealEnvelope } from "../relay-envelope";
6
- export { ActiveControllerRecipients } from "./active-controllers";
7
6
 
8
7
  type Job = { frame: string; bytes: number; resolve: () => void; reject: (error: Error) => void };
9
8
  const CLOSED = () => new Error("relay connection closed before delivery");
@@ -41,6 +41,8 @@ export type RelayClientOptions = {
41
41
  };
42
42
 
43
43
  export type SendOptions = {
44
+ /** Reply only to the authenticated requester; absent means all authorized peers. */
45
+ peerPublicKey?: string;
44
46
  /** Delivery lifetime for relay hold queues. Omit only for non-expiring hello packets. */
45
47
  ttlMs?: number;
46
48
  deliveryId?: string;
@@ -10,7 +10,7 @@
10
10
  * the Cloudflare Durable Object deployment.
11
11
  */
12
12
  import WebSocket from "ws";
13
- import { OutboundFrameQueue, sendEncryptedPayload, ActiveControllerRecipients } from "./outbound-scheduler";
13
+ import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
14
14
  import { createHash } from "node:crypto";
15
15
  import { Envelope, Payload, type Role } from "../protocol/schema";
16
16
  import { openFromPeersWithIdentity, openWithTransferKey, peerPublicKeys, seal, sealWithTransferKey } from "./crypto";
@@ -34,7 +34,6 @@ export class RelayClient {
34
34
  private livenessTimer?: NodeJS.Timeout;
35
35
  private awaitingPong = false;
36
36
  private readonly sessionKeys = new Map<string, string>();
37
- private readonly activeControllers = new ActiveControllerRecipients();
38
37
  /**
39
38
  * Delivery ids are relay-controlled metadata, so replay protection is bound
40
39
  * to the authenticated ciphertext itself. Keep a bounded process-local set:
@@ -200,9 +199,6 @@ export class RelayClient {
200
199
  this.sessionKeys.set(payload.sessionId, payload.key);
201
200
  }
202
201
  if (this.processingCiphertexts.has(fingerprint)) return;
203
- if (this.cfg.role === "machine" && (payload.type !== "hello" || payload.role === "phone")) {
204
- this.activeControllers.observe(peerPublicKey);
205
- }
206
202
  this.processingCiphertexts.add(fingerprint);
207
203
  try {
208
204
  const results = await Promise.all([...this.listeners].map(async (listener) => {
@@ -238,10 +234,11 @@ export class RelayClient {
238
234
  const ws = this.ws;
239
235
  if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
240
236
  if (!this.frames) throw new Error("relay not connected");
241
- const replaceable = this.cfg.role === "machine" && to === "phone"
242
- && options.reliable === false && payload.type === "session.sealed";
243
- await sendEncryptedPayload(this.frames, this.cfg,
244
- key => this.peerIsAllowed(key) && this.activeControllers.allows(key, replaceable),
237
+ if (options.peerPublicKey) {
238
+ await this.sendToPeer(payload, options.peerPublicKey, options);
239
+ return;
240
+ }
241
+ await sendEncryptedPayload(this.frames, this.cfg, key => this.peerIsAllowed(key),
245
242
  { payload, to, options });
246
243
  }
247
244
 
@@ -154,6 +154,7 @@ export const ProjectRepositoryGraph = z.object({
154
154
  repositoryId: z.string().trim().min(1).max(512),
155
155
  revision: z.string().trim().min(1).max(512),
156
156
  weavatrixVersion: z.string().trim().min(1).max(64),
157
+ sourceRevision: z.string().trim().min(1).max(512).optional(),
157
158
  analysisId: z.string().trim().min(1).max(128).optional(),
158
159
  analysisStatus: z.enum(["COMPLETE", "INCOMPLETE", "UNAVAILABLE"]).optional(),
159
160
  analysisErrorCode: z.string().regex(/^[A-Z_]{2,64}$/).optional(),
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ const scope = { sessionId: z.string().min(1).max(512), requestId: z.string().min(1).max(128) };
3
+ export const SessionWorkspaceQuery = z.object({ type: z.literal('session.workspace.query'), ...scope,
4
+ mode: z.enum(['directory', 'file']), path: z.string().max(4096), offset: z.number().int().nonnegative().optional(),
5
+ createdAt: z.number().finite() }).strict();
6
+ export type SessionWorkspaceQuery = z.infer<typeof SessionWorkspaceQuery>;
7
+ export const SessionWorkspaceResult = z.object({ type: z.literal('session.workspace.result'), ...scope,
8
+ path: z.string().max(4096), entries: z.array(z.object({ path: z.string().max(4096), name: z.string().max(1024),
9
+ kind: z.enum(['directory', 'file']), size: z.number().int().nonnegative().optional() }).strict()).max(200).optional(),
10
+ text: z.string().max(128 * 1024).optional(), truncated: z.boolean(),
11
+ nextOffset: z.number().int().nonnegative().optional(), unavailable: z.boolean().optional(),
12
+ createdAt: z.number().finite() }).strict();
13
+ export type SessionWorkspaceResult = z.infer<typeof SessionWorkspaceResult>;
@@ -1,6 +1,8 @@
1
1
  /** Public encrypted wire protocol entry point. */
2
2
  import { z } from "zod";
3
3
  import { SessionImageQuery, SessionImageChunk } from './messages/session-images';
4
+ import { SessionWorkspaceQuery, SessionWorkspaceResult } from './messages/sessions/workspace';
5
+ export { SessionWorkspaceQuery, SessionWorkspaceResult } from './messages/sessions/workspace';
4
6
  export { SessionImageQuery, SessionImageChunk } from './messages/session-images';
5
7
  import { ProviderHookTrust, ProviderHookTrustResult } from "./messages/provider-hooks";
6
8
  import {
@@ -126,6 +128,8 @@ export const Payload = z.union([
126
128
  SessionActivity,
127
129
  SessionImageQuery,
128
130
  SessionImageChunk,
131
+ SessionWorkspaceQuery,
132
+ SessionWorkspaceResult,
129
133
  CapabilityUsageStatus,
130
134
  SessionKeyGrant,
131
135
  SessionSealed,
@@ -1,15 +0,0 @@
1
- /** Activity routes replaceable traffic; authorization remains a separate check. */
2
- export class ActiveControllerRecipients {
3
- private readonly seen = new Map<string, number>();
4
-
5
- observe(peer: string, now = Date.now()): void {
6
- this.seen.set(peer, now);
7
- }
8
-
9
- allows(peer: string, replaceable: boolean, now = Date.now()): boolean {
10
- if (!replaceable) return true;
11
- const active = [...this.seen].filter(([, at]) => now >= at && now - at <= 90_000);
12
- // A newly connected transport has not heard its controllers yet.
13
- return active.length === 0 || active.some(([key]) => key === peer);
14
- }
15
- }