granttap-mcp 0.8.17 → 0.8.18

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 (36) hide show
  1. package/README.md +28 -4
  2. package/apps/bridge/src/bin/claude-hook.ts +3 -0
  3. package/apps/bridge/src/bin/codex-policy-hook.ts +3 -0
  4. package/apps/bridge/src/capabilities/skills.ts +25 -6
  5. package/apps/bridge/src/engine/README.md +11 -1
  6. package/apps/bridge/src/engine/engine-client.ts +2 -0
  7. package/apps/bridge/src/engine/engine-invocation-protocol.ts +117 -0
  8. package/apps/bridge/src/engine/engine-protocol.ts +13 -3
  9. package/apps/bridge/src/engine/invocation-decision-parse.ts +35 -0
  10. package/apps/bridge/src/engine/invocation-ingest.ts +167 -0
  11. package/apps/bridge/src/engine/invocation-log-reader.ts +71 -0
  12. package/apps/bridge/src/engine/invocation-parse.ts +120 -0
  13. package/apps/bridge/src/engine/invocation-query.ts +53 -0
  14. package/apps/bridge/src/engine/invocation-scope.ts +39 -0
  15. package/apps/bridge/src/mesh/catalog.ts +7 -1
  16. package/apps/bridge/src/mesh/map.ts +7 -2
  17. package/apps/bridge/src/mesh/observed-claims.ts +8 -3
  18. package/apps/bridge/src/mesh/runtime-handoff.ts +3 -1
  19. package/apps/bridge/src/mesh/runtime.ts +6 -2
  20. package/apps/bridge/src/mesh/scoped-view.ts +7 -6
  21. package/apps/bridge/src/mesh/store-support.ts +25 -5
  22. package/apps/bridge/src/mesh/store.ts +9 -6
  23. package/apps/bridge/src/monitor.ts +7 -0
  24. package/apps/bridge/src/policy/capability-fingerprint.ts +24 -4
  25. package/apps/bridge/src/policy/decision-log.ts +23 -1
  26. package/apps/bridge/src/policy/effective-action.ts +8 -5
  27. package/apps/bridge/src/session-keys.ts +2 -1
  28. package/apps/bridge/src/sessions/claude.ts +5 -0
  29. package/apps/bridge/src/sessions/codex.ts +5 -0
  30. package/apps/mcp/src/mcp-tools/mesh-resource.ts +5 -2
  31. package/cursor-plugin/README.md +7 -9
  32. package/docs/images/iphone-task-route.png +0 -0
  33. package/package.json +1 -1
  34. package/packages/protocol/messages/mesh-invocations.ts +81 -0
  35. package/packages/protocol/messages/mesh.ts +3 -0
  36. package/packages/protocol/schema.ts +4 -0
package/README.md CHANGED
@@ -54,15 +54,38 @@ and completion events through the existing `notify` tool, and read state from a
54
54
  Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
55
55
  Claims have TTLs; a colliding claim is rejected before it is recorded so agents
56
56
  can choose different work or contact the owner before escalating to Needs You.
57
+ Observed edit-tool requests are intent claims, not confirmed writes. Resource
58
+ identity includes the repository; equal relative paths from separate repositories
59
+ do not conflict, and another checkout is a coordination warning. The path
60
+ matcher respects segment boundaries, so `src/auth.ts` and `src/auth.ts.bak`
61
+ are distinct. A live repository reading refreshes HEAD after commit or checkout.
62
+
63
+ When a separately distributed, verified GrantTap Engine is enabled on the
64
+ computer, the bridge sends content-free Invocation events before its small Usage
65
+ buffer is trimmed. Claude Code, Codex, and Cursor transcript scanners record
66
+ native call identity, request, reported outcome, resource path, and explicit
67
+ source gaps. The Engine owns the durable journal and bounded cursor history;
68
+ the phone requests the latest or older page under the Project E2EE key. A
69
+ reported successful tool call alone does not prove a filesystem change, and
70
+ unavailable Engine history is reported as unavailable.
71
+ The bridge does not yet produce verified filesystem-change events or attach
72
+ revision-bound impact/consumer links to individual Invocations. It leaves that
73
+ evidence unknown instead of treating an edit request or reported success as a
74
+ confirmed content change.
75
+ Claude Code and Codex denials with native call IDs are recorded as separate,
76
+ metadata-only hook events with the applied rule and policy revision; an
77
+ unidentified Cursor decision is not assigned to an invented call. Script and
78
+ `SKILL.md` fingerprints include a content hash when the local file is readable,
79
+ separate from their path identity. Missing content evidence remains unknown.
57
80
 
58
81
  <p align="center">
59
82
  <img src="docs/images/iphone-projects-shared.png" width="200" alt="Projects: one owned by this phone, one shared by another">
60
83
  <img src="docs/images/iphone-project-mesh.png" width="200" alt="A Project: Governance, members and computers, Mesh status, repositories, Tasks">
61
- <img src="docs/images/iphone-task-route.png" width="200" alt="A Task: its executions, its claims, and who else is in its files">
84
+ <img src="docs/images/iphone-task-route.png" width="200" alt="A Task: executions, Runtime history, and resource claims">
62
85
  <img src="docs/images/iphone-handoff.png" width="200" alt="Task handoff: destination, push, and readiness checks">
63
86
  </p>
64
87
 
65
- <p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with its executions and claims · a handoff with its readiness checks</em></p>
88
+ <p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with executions, Runtime history, and claims · a handoff with its readiness checks</em></p>
66
89
 
67
90
  For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
68
91
  own session and sees the exact call. GrantTap attributes every `notify` to the
@@ -223,8 +246,9 @@ grok plugin marketplace add sergii-ziborov/granttap-mcp
223
246
  grok plugin install granttap --trust
224
247
  ```
225
248
 
226
- Import `sergii-ziborov/granttap-mcp` as a private Git plugin in Cursor. The
227
- MIT-licensed plugin is ready for Cursor's public review. Reload Cursor, then open
249
+ For Cursor, use the reviewed **GrantTap** Marketplace listing when it is visible
250
+ to your account, or import the public `sergii-ziborov/granttap-mcp` repository
251
+ once. Reload Cursor, then open
228
252
  **Cursor Customize → MCPs → GrantTap → Authenticate**. Complete authorization
229
253
  and any one-time QR pairing on `https://granttap.com/connect`.
230
254
 
@@ -88,6 +88,9 @@ async function handlePreToolUse(input: HookInput): Promise<void> {
88
88
  recordProjectDecision(input.session_id, {
89
89
  at: Date.now(), toolName: input.tool_name ?? "tool", reason: projectDecision.reason,
90
90
  ruleId: projectDecision.rule_id,
91
+ provider: "claude", nativeCallId: input.tool_use_id,
92
+ policyRevision: projectDecision.policy_revision,
93
+ artifactHash: projectDecision.artifactHash,
91
94
  });
92
95
  }
93
96
  writePermission("deny", projectDecision.reason);
@@ -43,6 +43,9 @@ async function main(): Promise<void> {
43
43
  if (input.session_id) {
44
44
  recordProjectDecision(input.session_id, {
45
45
  at: Date.now(), toolName: input.tool_name ?? "tool", reason: decision.reason, ruleId: decision.rule_id,
46
+ provider: "codex", nativeCallId: input.tool_use_id,
47
+ policyRevision: decision.policy_revision,
48
+ artifactHash: decision.artifactHash,
46
49
  });
47
50
  }
48
51
  deny(decision.reason);
@@ -7,6 +7,30 @@ import { ancestors } from "./descriptors";
7
7
  /** Skills available globally and along the task's repository path. */
8
8
  export function workspaceSkills(cwd: string | undefined): SkillInfo[] {
9
9
  const found = new Map<string, SkillInfo>();
10
+ for (const root of skillRoots(cwd)) {
11
+ for (const skill of skillsIn(root)) {
12
+ if (!found.has(skill.name)) found.set(skill.name, skill);
13
+ }
14
+ }
15
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
16
+ }
17
+
18
+ /** The source file behind a named skill, in the same precedence as the catalog. */
19
+ export function skillDefinitionPath(name: string, cwd: string | undefined): string | undefined {
20
+ for (const root of skillRoots(cwd)) {
21
+ let entries: string[];
22
+ try { entries = readdirSync(root); }
23
+ catch { continue; }
24
+ for (const entry of entries) {
25
+ if (entry.startsWith(".")) continue;
26
+ const path = join(root, entry, "SKILL.md");
27
+ if (frontmatter(path)?.name === name) return path;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ function skillRoots(cwd: string | undefined): string[] {
10
34
  const roots = [
11
35
  join(homedir(), ".cursor", "skills-cursor"),
12
36
  join(homedir(), ".agents", "skills"),
@@ -21,12 +45,7 @@ export function workspaceSkills(cwd: string | undefined): SkillInfo[] {
21
45
  );
22
46
  }
23
47
  }
24
- for (const root of roots) {
25
- for (const skill of skillsIn(root)) {
26
- if (!found.has(skill.name)) found.set(skill.name, skill);
27
- }
28
- }
29
- return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
48
+ return roots;
30
49
  }
31
50
 
32
51
  function skillsIn(root: string): SkillInfo[] {
@@ -17,7 +17,17 @@ bypass, paused gating, and auto-accept. Codex carries ASK from `PreToolUse` to
17
17
  `PermissionRequest` with a single-use exact-call marker; Cursor evaluates shell
18
18
  and redacted MCP fingerprints directly. A disabled or unavailable engine
19
19
  preserves the established GrantTap flow, and no hook request is retried inside
20
- its 50 ms policy budget.
20
+ its 2,000 ms policy budget. Repository impact is not computed synchronously in
21
+ the hook; unavailable impact remains explicitly unavailable.
22
+
23
+ `invocation-ingest.ts` reads bounded complete transcript lines and exact-call
24
+ hook denial metadata without forwarding tool arguments, output, or reason text.
25
+ Cursor decisions without a native call ID stay unattributed. Events are replayed
26
+ with stable IDs after an IPC failure. `invocation-query.ts` returns bounded
27
+ Engine history to the phone under the Project key, while
28
+ `invocation-scope.ts` exposes one Task slice through the caller's existing
29
+ opaque Mesh capability. The Engine, not this bridge or Usage telemetry, owns
30
+ the durable journal.
21
31
 
22
32
  The proprietary engine source and Project database do not live in this package.
23
33
 
@@ -196,6 +196,8 @@ function expectedResultOperation(
196
196
  if (operation === "project.get") return "project.found";
197
197
  if (operation === "project.list_bindings") return "project.bindings";
198
198
  if (operation === "project.upsert_binding") return "project.binding_upserted";
199
+ if (operation === "invocation.observe") return "invocation.observed";
200
+ if (operation === "invocation.history") return "invocation.history";
199
201
  if (operation === "policy.get") return "policy.found";
200
202
  if (operation === "policy.apply") return "policy.applied";
201
203
  if (operation === "policy.coverage") return "policy.coverage";
@@ -0,0 +1,117 @@
1
+ /** The Engine's content-free, durable per-call evidence wire contract. */
2
+ export type InvocationPhase =
3
+ | "requested" | "reported_success" | "reported_failure" | "reported_unknown"
4
+ | "denied" | "change_observed" | "source_gap";
5
+ export type InvocationSource = "transcript" | "hook" | "filesystem" | "scanner";
6
+
7
+ export type InvocationEvent = {
8
+ event_id: string;
9
+ invocation_id: string;
10
+ project_id: string;
11
+ task_id: string;
12
+ execution_id: string;
13
+ provider: string;
14
+ native_call_id: string;
15
+ session_id?: string | null;
16
+ tool_name: string;
17
+ phase: InvocationPhase;
18
+ source: InvocationSource;
19
+ occurred_at: number;
20
+ repository_id?: string | null;
21
+ worktree?: string | null;
22
+ resource?: string | null;
23
+ revision?: string | null;
24
+ content_hash?: string | null;
25
+ capability_artifact_hash?: string | null;
26
+ policy_revision?: number | null;
27
+ policy_rule_id?: string | null;
28
+ };
29
+
30
+ export type InvocationHistoryQuery = {
31
+ project_id: string;
32
+ task_id?: string | null;
33
+ after_sequence?: number | null;
34
+ limit?: number | null;
35
+ tail?: boolean;
36
+ before_sequence?: number | null;
37
+ };
38
+
39
+ export type SequencedInvocationEvent = { sequence: number; event: InvocationEvent };
40
+ export type InvocationHistoryPage = {
41
+ events: SequencedInvocationEvent[];
42
+ next_sequence: number;
43
+ has_more: boolean;
44
+ previous_sequence: number;
45
+ has_older: boolean;
46
+ };
47
+
48
+ export type InvocationEngineOperation =
49
+ | { operation: "invocation.observe"; input: InvocationEvent }
50
+ | { operation: "invocation.history"; input: InvocationHistoryQuery };
51
+ export type InvocationEngineResult =
52
+ | { operation: "invocation.observed"; sequence: number }
53
+ | { operation: "invocation.history"; page: InvocationHistoryPage };
54
+
55
+ const phases = new Set<InvocationPhase>([
56
+ "requested", "reported_success", "reported_failure", "reported_unknown",
57
+ "denied", "change_observed", "source_gap",
58
+ ]);
59
+ const sources = new Set<InvocationSource>(["transcript", "hook", "filesystem", "scanner"]);
60
+
61
+ export function parseInvocationResult(result: Record<string, unknown>, invalid: () => never): boolean {
62
+ if (result.operation === "invocation.observed") {
63
+ if (!positiveInteger(result.sequence)) invalid();
64
+ return true;
65
+ }
66
+ if (result.operation !== "invocation.history") return false;
67
+ const page = record(result.page, invalid);
68
+ if (!Array.isArray(page.events) || page.events.length > 32
69
+ || !safeInteger(page.next_sequence) || typeof page.has_more !== "boolean"
70
+ || !safeInteger(page.previous_sequence) || typeof page.has_older !== "boolean") invalid();
71
+ let prior = 0;
72
+ for (const raw of page.events as unknown[]) {
73
+ const row = record(raw, invalid);
74
+ if (!positiveInteger(row.sequence) || row.sequence <= prior || row.sequence > page.next_sequence) invalid();
75
+ prior = row.sequence;
76
+ parseInvocationEvent(row.event, invalid);
77
+ }
78
+ return true;
79
+ }
80
+
81
+ function parseInvocationEvent(value: unknown, invalid: () => never): void {
82
+ const event = record(value, invalid);
83
+ for (const [key, max] of [
84
+ ["event_id", 128], ["invocation_id", 128], ["project_id", 128], ["task_id", 128],
85
+ ["execution_id", 128], ["provider", 32], ["native_call_id", 128], ["tool_name", 160],
86
+ ] as const) boundedString(event[key], max, invalid);
87
+ if (!phases.has(event.phase as InvocationPhase) || !sources.has(event.source as InvocationSource)
88
+ || !safeInteger(event.occurred_at)) invalid();
89
+ for (const key of ["repository_id", "worktree", "resource", "revision"] as const) {
90
+ if (event[key] != null) boundedString(event[key], 512, invalid);
91
+ }
92
+ if (event.session_id != null) boundedString(event.session_id, 128, invalid);
93
+ for (const key of ["content_hash", "capability_artifact_hash"] as const) {
94
+ if (event[key] != null && (typeof event[key] !== "string" || !/^[a-f0-9]{64}$/i.test(event[key]))) invalid();
95
+ }
96
+ if (event.policy_revision != null && !safeInteger(event.policy_revision)) invalid();
97
+ if (event.policy_rule_id != null) boundedString(event.policy_rule_id, 128, invalid);
98
+ if (event.phase === "change_observed" && (event.source !== "filesystem"
99
+ || !event.repository_id || !event.resource || !event.revision || !event.content_hash)) invalid();
100
+ }
101
+
102
+ function record(value: unknown, invalid: () => never): Record<string, unknown> {
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
104
+ return value as Record<string, unknown>;
105
+ }
106
+
107
+ function safeInteger(value: unknown): value is number {
108
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
109
+ }
110
+
111
+ function positiveInteger(value: unknown): value is number {
112
+ return safeInteger(value) && value > 0;
113
+ }
114
+
115
+ function boundedString(value: unknown, max: number, invalid: () => never): void {
116
+ if (typeof value !== "string" || value.trim() === "" || value.length > max || /[\u0000-\u001f]/.test(value)) invalid();
117
+ }
@@ -3,6 +3,15 @@ import {
3
3
  type EnginePolicyOperation,
4
4
  type EnginePolicyResult,
5
5
  } from "./engine-policy-protocol";
6
+ import {
7
+ parseInvocationResult,
8
+ type InvocationEngineOperation,
9
+ type InvocationEngineResult,
10
+ } from "./engine-invocation-protocol";
11
+
12
+ export type {
13
+ InvocationEvent, InvocationHistoryPage, InvocationHistoryQuery, InvocationPhase, InvocationSource,
14
+ } from "./engine-invocation-protocol";
6
15
 
7
16
  export const ENGINE_PROTOCOL_VERSION = 1 as const;
8
17
  export const MAX_ENGINE_FRAME_BYTES = 64 * 1024;
@@ -53,7 +62,7 @@ export type EngineProjectBinding = {
53
62
  last_seen_at: number;
54
63
  };
55
64
 
56
- export type EngineOperation = EnginePolicyOperation
65
+ export type EngineOperation = EnginePolicyOperation | InvocationEngineOperation
57
66
  | { operation: "engine.ping" }
58
67
  | { operation: "engine.version" }
59
68
  | {
@@ -77,7 +86,7 @@ export type EngineRequest = {
77
86
  request_id: string;
78
87
  } & EngineOperation;
79
88
 
80
- export type EngineResult = EnginePolicyResult
89
+ export type EngineResult = EnginePolicyResult | InvocationEngineResult
81
90
  | { operation: "engine.pong"; engine_version: string }
82
91
  | {
83
92
  operation: "engine.version";
@@ -202,7 +211,8 @@ function parseResult(value: unknown): EngineResult {
202
211
  result.bindings.forEach(parseBinding);
203
212
  } else if (operation === "project.binding_upserted") {
204
213
  parseBinding(result.binding);
205
- } else if (!parsePolicyResult(result, invalidResult)) {
214
+ } else if (!parsePolicyResult(result, invalidResult)
215
+ && !parseInvocationResult(result, invalidResult)) {
206
216
  throw new EngineProtocolError("engine result operation is unsupported");
207
217
  }
208
218
  return result as EngineResult;
@@ -0,0 +1,35 @@
1
+ import { sourceGapFact, type InvocationFact } from "./invocation-parse";
2
+
3
+ /** Metadata-only hook refusal. No arguments, outputs, or explanation text. */
4
+ export function parseInvocationDecision(
5
+ line: string, provider: "claude" | "codex" | "cursor", offset: number,
6
+ ): InvocationFact[] {
7
+ let value: unknown;
8
+ try { value = JSON.parse(line) as unknown; }
9
+ catch { return [sourceGapFact(offset)]; }
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ return [sourceGapFact(offset)];
12
+ }
13
+ const record = value as Record<string, unknown>;
14
+ if (record.provider !== provider) return [];
15
+ if (typeof record.nativeCallId !== "string" || !record.nativeCallId.trim()
16
+ || record.nativeCallId.length > 128 || /[\u0000-\u001f]/.test(record.nativeCallId)
17
+ || typeof record.toolName !== "string" || !record.toolName.trim()
18
+ || record.toolName.length > 160 || /[\u0000-\u001f]/.test(record.toolName)
19
+ || typeof record.at !== "number" || !Number.isSafeInteger(record.at) || record.at < 0) {
20
+ return [sourceGapFact(offset)];
21
+ }
22
+ const revision = record.policyRevision;
23
+ const rule = record.ruleId;
24
+ const artifact = record.artifactHash;
25
+ return [{
26
+ callId: record.nativeCallId, toolName: record.toolName, phase: "denied",
27
+ source: "hook", occurredAt: record.at,
28
+ ...(typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0
29
+ ? { policyRevision: revision } : {}),
30
+ ...(typeof rule === "string" && rule.trim() && rule.length <= 128
31
+ && !/[\u0000-\u001f]/.test(rule) ? { policyRuleId: rule } : {}),
32
+ ...(typeof artifact === "string" && /^[a-f0-9]{64}$/i.test(artifact)
33
+ ? { capabilityArtifactHash: artifact.toLowerCase() } : {}),
34
+ }];
35
+ }
@@ -0,0 +1,167 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { SessionInfo } from "../../../../packages/protocol/schema";
3
+ import { inspectRepository, type RepositoryFacts } from "../mesh/catalog";
4
+ import { computerId } from "../mesh/computer-identity";
5
+ import { repositoryRelative } from "../mesh/observed-claims";
6
+ import { claudeTranscriptPaths } from "../sessions/claude";
7
+ import { codexTranscriptPaths } from "../sessions/codex";
8
+ import { cursorFiles } from "../sessions/cursor/scan";
9
+ import { configDir } from "../config/paths";
10
+ import { join } from "node:path";
11
+ import { invocationDecisionPath } from "../policy/decision-log";
12
+ import { EngineClient } from "./engine-client";
13
+ import { engineFeatureEnabled } from "./engine-supervisor";
14
+ import { readInvocationBatch, type InvocationLogCursor } from "./invocation-log-reader";
15
+ import {
16
+ parseInvocationLine, sourceGapFact, type InvocationFact, type InvocationParseState,
17
+ } from "./invocation-parse";
18
+ import type { InvocationEvent } from "./engine-invocation-protocol";
19
+ import { parseInvocationDecision } from "./invocation-decision-parse";
20
+
21
+ type Provider = "claude" | "codex" | "cursor";
22
+ type SourceState = { cursor: InvocationLogCursor; parse: InvocationParseState };
23
+ type Client = Pick<EngineClient, "request">;
24
+
25
+ export type InvocationIngestOptions = {
26
+ client: Client;
27
+ enabled: () => boolean;
28
+ computer: () => string;
29
+ paths: (session: SessionInfo) => string[];
30
+ inspect: (cwd: string) => RepositoryFacts;
31
+ decisionPath?: (session: SessionInfo) => string;
32
+ };
33
+
34
+ function transcriptPaths(session: SessionInfo): string[] {
35
+ if (session.agent === "claude") return claudeTranscriptPaths(session.sessionId);
36
+ if (session.agent === "codex") return codexTranscriptPaths(session.sessionId);
37
+ if (session.agent === "cursor") return cursorFiles(session.sessionId)?.map((file) => file.path) ?? [];
38
+ return [];
39
+ }
40
+
41
+ export function createInvocationIngestor(options: InvocationIngestOptions) {
42
+ const states = new Map<string, SourceState>();
43
+ const decisionStates = new Map<string, InvocationLogCursor>();
44
+ return {
45
+ async ingest(sessions: readonly SessionInfo[]): Promise<number> {
46
+ if (!options.enabled()) return 0;
47
+ const candidates = sessions.filter((session) => session.projectId && session.taskId
48
+ && ["claude", "codex", "cursor"].includes(session.agent));
49
+ if (candidates.length === 0) return 0;
50
+ try { await options.client.request({ operation: "engine.ping" }, { timeoutMs: 200 }); }
51
+ catch { return 0; }
52
+ let accepted = 0;
53
+ for (const session of candidates) {
54
+ const provider = session.agent as Provider;
55
+ const facts = session.cwd ? options.inspect(session.cwd) : undefined;
56
+ for (const path of options.paths(session)) {
57
+ const key = `${provider}\0${session.sessionId}\0${path}`;
58
+ const known = states.get(key);
59
+ let batch;
60
+ try { batch = readInvocationBatch(path, known?.cursor); }
61
+ catch { continue; }
62
+ const parse: InvocationParseState = {
63
+ pending: new Map(known?.parse.pending ?? []),
64
+ };
65
+ const records = [
66
+ ...batch.gaps.map((offset) => ({ offset, gap: true as const })),
67
+ ...batch.lines.map((row) => ({ ...row, gap: false as const })),
68
+ ].sort((a, b) => a.offset - b.offset || Number(b.gap) - Number(a.gap));
69
+ const thread = hash([path]);
70
+ const events: InvocationEvent[] = [];
71
+ for (const record of records) {
72
+ if (record.gap) parse.pending.clear();
73
+ const observed = record.gap
74
+ ? [sourceGapFact(record.offset)]
75
+ : parseInvocationLine(provider, record.line, parse, record.offset);
76
+ for (const fact of observed) {
77
+ events.push(eventFor(session, provider, options.computer(), thread, record.offset, fact, facts));
78
+ }
79
+ }
80
+ try {
81
+ for (const event of events) {
82
+ await options.client.request({ operation: "invocation.observe", input: event });
83
+ accepted += 1;
84
+ }
85
+ } catch {
86
+ // No cursor advance: the next pass replays this batch under stable
87
+ // event IDs. Engine deduplicates already committed evidence.
88
+ return accepted;
89
+ }
90
+ states.set(key, { cursor: batch.next, parse });
91
+ }
92
+ const decisionPath = options.decisionPath?.(session);
93
+ if (decisionPath) {
94
+ const key = `${provider}\0${session.sessionId}\0${decisionPath}`;
95
+ let batch;
96
+ try { batch = readInvocationBatch(decisionPath, decisionStates.get(key)); }
97
+ catch { continue; }
98
+ const thread = hash([decisionPath]);
99
+ const events = [
100
+ ...batch.gaps.map((offset) => ({ offset, gap: true as const })),
101
+ ...batch.lines.map((row) => ({ ...row, gap: false as const })),
102
+ ].sort((a, b) => a.offset - b.offset).flatMap((record) =>
103
+ (record.gap ? [sourceGapFact(record.offset)]
104
+ : parseInvocationDecision(record.line, provider, record.offset))
105
+ .map((fact) => eventFor(session, provider, options.computer(), thread, record.offset, fact, facts)));
106
+ try {
107
+ for (const event of events) {
108
+ await options.client.request({ operation: "invocation.observe", input: event });
109
+ accepted += 1;
110
+ }
111
+ } catch { return accepted; }
112
+ decisionStates.set(key, batch.next);
113
+ }
114
+ }
115
+ return accepted;
116
+ },
117
+ };
118
+ }
119
+
120
+ function eventFor(
121
+ session: SessionInfo, provider: Provider, computer: string,
122
+ thread: string, offset: number, fact: InvocationFact, repository?: RepositoryFacts,
123
+ ): InvocationEvent {
124
+ // A native session may be observed again after handoff. The computer is part
125
+ // of the Execution identity, while Cursor child transcripts also need their
126
+ // thread path to avoid reusing a call ID from a sibling conversation.
127
+ const invocationId = hash([computer, provider, session.sessionId,
128
+ provider === "cursor" || fact.phase === "source_gap" ? thread : "", fact.callId]);
129
+ const resource = fact.resource && repository
130
+ ? repositoryRelative(fact.resource, repository.root) : undefined;
131
+ return {
132
+ event_id: hash([invocationId, thread, String(offset), fact.phase, fact.source,
133
+ resource ?? "", String(fact.occurredAt)]),
134
+ invocation_id: invocationId,
135
+ project_id: session.projectId!,
136
+ task_id: session.taskId!,
137
+ execution_id: hash([computer, provider, session.sessionId]),
138
+ provider, native_call_id: fact.callId, session_id: session.sessionId,
139
+ tool_name: fact.toolName,
140
+ phase: fact.phase, source: fact.source, occurred_at: fact.occurredAt,
141
+ repository_id: repository?.canonicalRepositoryId,
142
+ worktree: session.worktree ?? repository?.worktree,
143
+ resource,
144
+ policy_revision: fact.policyRevision,
145
+ policy_rule_id: fact.policyRuleId,
146
+ capability_artifact_hash: fact.capabilityArtifactHash,
147
+ };
148
+ }
149
+
150
+ function hash(parts: string[]): string {
151
+ return createHash("sha256").update(parts.join("\0")).digest("hex");
152
+ }
153
+
154
+ const defaultClient = new EngineClient({ socketPath: join(configDir(), "engine.sock") });
155
+ const defaultIngestor = createInvocationIngestor({
156
+ client: defaultClient, enabled: engineFeatureEnabled, computer: computerId,
157
+ paths: transcriptPaths, inspect: inspectRepository,
158
+ decisionPath: (session) => invocationDecisionPath(session.sessionId),
159
+ });
160
+
161
+ let ongoing: Promise<number> | undefined;
162
+
163
+ export function ingestRuntimeInvocations(sessions: readonly SessionInfo[]): Promise<number> {
164
+ if (ongoing) return ongoing;
165
+ ongoing = defaultIngestor.ingest(sessions).finally(() => { ongoing = undefined; });
166
+ return ongoing;
167
+ }
@@ -0,0 +1,71 @@
1
+ import { closeSync, lstatSync, openSync, readSync } from "node:fs";
2
+
3
+ const MAX_INITIAL_BYTES = 8 * 1024 * 1024;
4
+ const MAX_BATCH_BYTES = 1024 * 1024;
5
+ const MAX_LINE_BYTES = 256 * 1024;
6
+
7
+ export type InvocationLogCursor = {
8
+ dev: number;
9
+ ino: number;
10
+ offset: number;
11
+ lineStart: number;
12
+ partial: Buffer;
13
+ skipping: boolean;
14
+ };
15
+
16
+ export type InvocationLogBatch = {
17
+ lines: Array<{ line: string; offset: number }>;
18
+ gaps: number[];
19
+ next: InvocationLogCursor;
20
+ };
21
+
22
+ /** A bounded, resumable JSONL read; the caller commits `next` only after Engine accepts the batch. */
23
+ export function readInvocationBatch(path: string, cursor?: InvocationLogCursor): InvocationLogBatch {
24
+ const stat = lstatSync(path);
25
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("transcript is not a regular file");
26
+ const rotated = cursor && (cursor.dev !== stat.dev || cursor.ino !== stat.ino || stat.size < cursor.offset);
27
+ const reuse = cursor && !rotated;
28
+ const initial = reuse ? cursor.offset : Math.max(0, stat.size - MAX_INITIAL_BYTES);
29
+ const gaps = rotated ? [cursor.offset] : initial > 0 ? [initial] : [];
30
+ const start = initial;
31
+ const length = Math.min(MAX_BATCH_BYTES, Math.max(0, stat.size - start));
32
+ const chunk = Buffer.allocUnsafe(length);
33
+ let read = 0;
34
+ if (length > 0) {
35
+ const fd = openSync(path, "r");
36
+ try { read = readSync(fd, chunk, 0, length, start); }
37
+ finally { closeSync(fd); }
38
+ }
39
+ const offset = start + read;
40
+ const prior = reuse ? cursor.partial : Buffer.alloc(0);
41
+ const combined = Buffer.concat([prior, chunk.subarray(0, read)]);
42
+ const base = start - prior.length;
43
+ const lines: InvocationLogBatch["lines"] = [];
44
+ let lineStart = reuse ? cursor.lineStart : start;
45
+ let skipping = reuse ? cursor.skipping : initial > 0;
46
+ let segmentStart = 0;
47
+ for (let index = 0; index < combined.length; index += 1) {
48
+ if (combined[index] !== 10) continue;
49
+ const size = index - segmentStart;
50
+ if (!skipping && size <= MAX_LINE_BYTES) {
51
+ const line = combined.subarray(segmentStart, index).toString("utf8");
52
+ lines.push({ line, offset: lineStart });
53
+ } else if (!skipping) {
54
+ gaps.push(lineStart);
55
+ }
56
+ segmentStart = index + 1;
57
+ lineStart = base + segmentStart;
58
+ skipping = false;
59
+ }
60
+ const trailing = combined.subarray(segmentStart);
61
+ let partial = skipping ? Buffer.alloc(0) : Buffer.from(trailing);
62
+ if (!skipping && partial.length > MAX_LINE_BYTES) {
63
+ gaps.push(lineStart);
64
+ skipping = true;
65
+ partial = Buffer.alloc(0);
66
+ }
67
+ return {
68
+ lines, gaps,
69
+ next: { dev: stat.dev, ino: stat.ino, offset, lineStart, partial, skipping },
70
+ };
71
+ }