granttap-mcp 0.8.55 → 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 (28) 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 +4 -28
  9. package/apps/bridge/src/monitor/handlers/inbound.ts +5 -3
  10. package/apps/bridge/src/monitor/mesh-publication/README.md +3 -0
  11. package/apps/bridge/src/monitor/mesh-publication/enrichment.ts +11 -0
  12. package/apps/bridge/src/monitor/mesh-publication/index.ts +4 -4
  13. package/apps/bridge/src/task-files/README.md +16 -0
  14. package/apps/bridge/src/task-files/index.ts +96 -0
  15. package/apps/bridge/src/task-files/relay.ts +14 -0
  16. package/apps/mcp/src/desktop/engine-bridge.ts +1 -1
  17. package/apps/mcp/src/desktop/operations/index.ts +2 -0
  18. package/apps/mcp/src/desktop/workspace/README.md +8 -0
  19. package/apps/mcp/src/desktop/workspace/task-files.ts +30 -0
  20. package/cursor-plugin/README.md +1 -1
  21. package/cursor-plugin/mcp.json +1 -1
  22. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  23. package/docs/images/iphone-chat-files.png +0 -0
  24. package/docs/images/iphone-chat-work-summary.png +0 -0
  25. package/package.json +1 -1
  26. package/packages/protocol/messages/mesh/snapshot.ts +1 -0
  27. package/packages/protocol/messages/sessions/workspace.ts +13 -0
  28. package/packages/protocol/schema.ts +4 -0
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
  /**
@@ -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";
@@ -84,7 +85,7 @@ export async function handleMonitorMessage(
84
85
  return true;
85
86
  } else if (payload.type === "session.subscribe") {
86
87
  return handleInboundSubscription(client, payload, subscriptions, publish, input.peerPublicKey);
87
- } else if (payload.type === "session.events" || payload.type === "session.image.query") {
88
+ } else if (payload.type === "session.events" || payload.type === "session.image.query" || payload.type === "session.workspace.query") {
88
89
  return handleSessionRead(client, payload, input.peerPublicKey);
89
90
  } else if (payload.type === "sessions.refresh") {
90
91
  return handleInboundRefresh(client, payload, publish);
@@ -277,9 +278,10 @@ function handleInboundSubscription(
277
278
  }
278
279
 
279
280
  async function handleSessionRead(client: RelayClient,
280
- payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }>,
281
+ payload: Extract<Payload, { type: 'session.events' | 'session.image.query' | 'session.workspace.query' }>,
281
282
  peerPublicKey?: string): Promise<boolean> {
282
- if (payload.type === 'session.image.query') await publishSessionImage(client, payload, peerPublicKey);
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);
283
285
  else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId,
284
286
  { ...payload, peerPublicKey }).catch(() => false);
285
287
  return true;
@@ -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
  }
@@ -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
+ }
@@ -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",
@@ -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.55
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.55\";\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.55";
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.55",
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": {
@@ -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,