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.
- package/README.md +21 -0
- package/apps/bridge/src/engine/README.md +5 -0
- package/apps/bridge/src/engine/runtime/engine-projects.ts +15 -10
- package/apps/bridge/src/engine/runtime/repository-graph/cache.ts +54 -0
- package/apps/bridge/src/engine/runtime/repository-graph-jobs.ts +47 -5
- package/apps/bridge/src/host/session-key-store/README.md +12 -0
- package/apps/bridge/src/host/session-key-store/index.ts +102 -0
- package/apps/bridge/src/host/session-keys.ts +4 -28
- package/apps/bridge/src/monitor/handlers/inbound.ts +5 -3
- package/apps/bridge/src/monitor/mesh-publication/README.md +3 -0
- package/apps/bridge/src/monitor/mesh-publication/enrichment.ts +11 -0
- package/apps/bridge/src/monitor/mesh-publication/index.ts +4 -4
- package/apps/bridge/src/task-files/README.md +16 -0
- package/apps/bridge/src/task-files/index.ts +96 -0
- package/apps/bridge/src/task-files/relay.ts +14 -0
- package/apps/mcp/src/desktop/engine-bridge.ts +1 -1
- package/apps/mcp/src/desktop/operations/index.ts +2 -0
- package/apps/mcp/src/desktop/workspace/README.md +8 -0
- package/apps/mcp/src/desktop/workspace/task-files.ts +30 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/docs/images/iphone-chat-files.png +0 -0
- package/docs/images/iphone-chat-work-summary.png +0 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh/snapshot.ts +1 -0
- package/packages/protocol/messages/sessions/workspace.ts +13 -0
- 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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/cursor-plugin/README.md
CHANGED
|
@@ -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.
|
|
13
|
+
npm install -g granttap-mcp@0.8.56
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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;
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -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,
|