granttap-mcp 0.8.86 → 0.8.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +13 -1
  2. package/apps/bridge/src/host/session-keys.ts +5 -1
  3. package/apps/bridge/src/monitor/capability-publication/index.ts +9 -4
  4. package/apps/bridge/src/monitor/handlers/user-message.ts +12 -9
  5. package/apps/bridge/src/monitor/support/runtime.ts +8 -5
  6. package/apps/bridge/src/reply/grok-response/README.md +20 -0
  7. package/apps/bridge/src/reply/grok-response/index.ts +48 -0
  8. package/apps/bridge/src/reply/process/index.ts +14 -1
  9. package/apps/bridge/src/reply/process/provider-headless.ts +3 -29
  10. package/apps/bridge/src/session-catalog/README.md +5 -4
  11. package/apps/bridge/src/session-catalog/publish-heads.ts +10 -4
  12. package/apps/bridge/src/sessions/README.md +8 -0
  13. package/apps/bridge/src/sessions/index.ts +26 -5
  14. package/apps/bridge/src/sessions/native-capability-history/README.md +20 -0
  15. package/apps/bridge/src/sessions/native-capability-history/completed-item.ts +31 -0
  16. package/apps/bridge/src/sessions/native-capability-history/index.ts +67 -0
  17. package/apps/bridge/src/sessions/native-capability-history/reader.js +97 -0
  18. package/apps/bridge/src/sessions/scan/claude/activity.ts +10 -3
  19. package/apps/bridge/src/sessions/scan/claude/index.ts +1 -0
  20. package/apps/bridge/src/sessions/scan/claude/parse.ts +6 -2
  21. package/apps/bridge/src/sessions/scan/claude/recorded-usage/README.md +17 -0
  22. package/apps/bridge/src/sessions/scan/claude/recorded-usage/index.ts +60 -0
  23. package/apps/bridge/src/sessions/scan/codex/activity.ts +15 -5
  24. package/apps/bridge/src/sessions/scan/grok.ts +12 -2
  25. package/apps/bridge/src/sessions/support/capability-totals.ts +0 -0
  26. package/apps/bridge/src/sessions/telemetry/execution-summaries.ts +56 -0
  27. package/apps/mcp/src/desktop/task-activity-worker.ts +4 -4
  28. package/apps/mcp/src/http/README.md +15 -0
  29. package/apps/mcp/src/http/http-server.ts +1 -3
  30. package/cursor-plugin/README.md +1 -1
  31. package/cursor-plugin/mcp.json +1 -1
  32. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  33. package/package.json +1 -1
  34. package/packages/core/relay-client-types.ts +6 -0
  35. package/packages/core/relay-client.ts +14 -19
  36. package/packages/core/relay-envelope.ts +7 -3
  37. package/packages/core/relay-inbound/index.ts +2 -0
  38. package/packages/core/relay-inbound/wait-for.ts +19 -0
  39. package/packages/core/retained-state/README.md +23 -0
  40. package/packages/core/retained-state/index.ts +25 -0
  41. package/packages/protocol/messages/capabilities.ts +17 -0
  42. package/packages/protocol/schema.ts +4 -0
package/README.md CHANGED
@@ -155,7 +155,8 @@ device pairing without a passkey account.
155
155
 
156
156
  - Claude Code and Codex: primary local control and continuation paths.
157
157
  - Cursor (Beta): Task visibility and supported local controls, with narrower coverage.
158
- - Grok Build: observable work where the installed runtime exposes it.
158
+ - Grok Build: local history and headless follow-ups through an installed,
159
+ authenticated CLI; provider hook and scoped Mesh coverage remain narrower.
159
160
 
160
161
  GrantTap reports the depth each provider actually exposes. Visibility does not
161
162
  imply deterministic remote blocking or full mobile continuation.
@@ -477,6 +478,13 @@ grok plugin marketplace add sergii-ziborov/granttap-mcp
477
478
  grok plugin install granttap --trust
478
479
  ```
479
480
 
481
+ In Grok, open `/mcps` and authenticate the GrantTap server, then run
482
+ `/granttap-connect`. GrantTap's phone pairing and Grok's permission to use that
483
+ MCP server are separate. An existing pairing does not authorize a new MCP client.
484
+ If a Grok session does not start responding within one minute, GrantTap asks you
485
+ to inspect the original task before retrying. A turn that has started can keep
486
+ running; its later failure does not mean that the message was never delivered.
487
+
480
488
  For Cursor, use the reviewed **GrantTap** Marketplace listing when it is visible
481
489
  to your account. Reload Cursor. On this computer, run `granttap setup`. Pair
482
490
  and change settings on [granttap.com/connect](https://granttap.com/connect)
@@ -843,3 +851,7 @@ sustained structured work in a different checkout without interpreting chat
843
851
  titles. Task identity and its original coordination/access scope survive.
844
852
 
845
853
  See [repository observations](apps/bridge/src/mesh/repository-details/README.md).
854
+
855
+ The relay can retain recipient-encrypted catalogs, Mesh snapshots, capability usage and recent chat heads for reconnect without the computer. This bounded seven-day read cache is separate from the durable encrypted command queue. See [relay state publication](packages/core/retained-state/README.md). The relay never receives decryption keys or readable chat content.
856
+
857
+ Tools & Skills matches recorded usage to the exact computer, provider and execution. Codex's native history projection supplies actual MCP invocation IDs, results and durations, including calls made through Code Mode. Claude Code uses observed tool-use, result and Skill records, aggregating MCP and skill calls before its recent-detail cache is trimmed. Execution summaries remain available when older call details fall outside the recent-event budget. Counts describe recorded evidence; installation and permission do not prove usage. Missing resource measurements remain unknown. See [native capability history](apps/bridge/src/sessions/native-capability-history/README.md).
@@ -1,3 +1,4 @@
1
+ import { retainedPayloadOptions } from "../../../../packages/core/retained-state";
1
2
  import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { loadSessionKeys, persistedSessionKey } from "./session-key-store";
@@ -70,6 +71,9 @@ async function sendScopedPayload(input: {
70
71
  options: SendOptions;
71
72
  }): Promise<void> {
72
73
  const { client, payload, sessionId, purpose, to, options } = input;
74
+ const retained = retainedPayloadOptions(payload);
75
+ const grantOptions = retained.stateKey ? {}
76
+ : { stateKey: undefined, stateRevision: undefined, statePriority: undefined, stateGroup: undefined };
73
77
  const key = sessionKey(sessionId);
74
78
  client.setSessionKey(sessionId, key);
75
79
  await client.send({
@@ -78,6 +82,6 @@ async function sendScopedPayload(input: {
78
82
  key,
79
83
  purpose,
80
84
  createdAt: Date.now(),
81
- }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey });
85
+ }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey, ...grantOptions });
82
86
  await client.sendSession(payload, sessionId, to, options);
83
87
  }
@@ -1,7 +1,7 @@
1
1
  import { setImmediate } from "node:timers/promises";
2
2
  import type { RelayClient } from "../../../../../packages/core/relay-client";
3
3
  import type { SessionInfo, SessionsStatus } from "../../../../../packages/protocol/schema";
4
- import { scanCapabilityUsage, scopeCapabilityUsageToRoom } from "../../sessions";
4
+ import { scanRecordedCapabilityUsage, scopeCapabilityUsageToRoom } from "../../sessions";
5
5
 
6
6
  type Publication = { running: boolean; closed: boolean; lastPublishedAt: number };
7
7
  const publications = new WeakMap<RelayClient, Publication>();
@@ -16,11 +16,16 @@ export function publishMonitorCapabilities(client: RelayClient, status: Sessions
16
16
  const work = (async () => {
17
17
  await setImmediate();
18
18
  if (state.closed || !client.isConnected) return;
19
+ const publish = async (sessions: SessionInfo[]) => {
20
+ const usage = await scanRecordedCapabilityUsage(sessions);
21
+ if (state.closed || !client.isConnected) return;
22
+ await client.send(scopeCapabilityUsageToRoom(usage, client.room), "phone",
23
+ { ttlMs: 90_000, reliable: false });
24
+ };
25
+ await publish(status.sessions);
19
26
  const older = await history();
20
27
  if (state.closed || !client.isConnected) return;
21
- const usage = scanCapabilityUsage([...status.sessions, ...older]);
22
- await client.send(scopeCapabilityUsageToRoom(usage, client.room), "phone",
23
- { ttlMs: 90_000, reliable: false });
28
+ if (older.length) await publish([...status.sessions, ...older]);
24
29
  state.lastPublishedAt = Date.now();
25
30
  })();
26
31
  void work.catch(() => {}).finally(() => { state.running = false; });
@@ -98,20 +98,20 @@ async function startNewAgentTask(input: {
98
98
  }
99
99
  const settle = async (result: ReplyResult, late: boolean) => {
100
100
  input.origin?.registry.complete(input.origin.scope,
101
- { ok: result.ok, sessionId: result.ok ? result.sessionId : undefined });
102
- if (!result.ok && message.messageId) {
101
+ { ok: result.ok || result.delivery === 'native', sessionId: result.ok ? result.sessionId : undefined });
102
+ if (!result.ok && message.messageId && result.delivery !== 'native') {
103
103
  if (late) rejectCompletedDelivery(message.messageId, result.error);
104
104
  await sendDeliveryReceipt(client, message.messageId, 'rejected', result.error,
105
105
  undefined, input.peerPublicKey);
106
106
  }
107
107
  return result.ok ? say(result.text, result.sessionId, true)
108
- : say(late ? `The ${displayName} task stopped: ${result.error}`
108
+ : say(late || result.delivery === 'native' ? `The ${displayName} task stopped: ${result.error}`
109
109
  : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
110
110
  };
111
111
  const early = await acceptWhenStarted(run, (result) => settle(result, true));
112
112
  if (early === "started") return "accepted";
113
113
  await settle(early, false);
114
- return early.ok ? "accepted" : "rejected";
114
+ return early.ok || early.delivery === 'native' ? "accepted" : "rejected";
115
115
  }
116
116
 
117
117
  export async function handleUserMessage(
@@ -196,15 +196,18 @@ export async function handleUserMessage(
196
196
  }
197
197
  if (result.ok) await say(result.text, result.sessionId ?? target.sessionId, true);
198
198
  else {
199
- if (late && message.messageId) rejectCompletedDelivery(message.messageId, result.error);
200
- if (message.messageId) await sendDeliveryReceipt(client, message.messageId, 'rejected',
201
- result.error, target.sessionId, peerPublicKey);
202
- await say(late ? `The run stopped: ${result.error}` : `Could not deliver the message: ${result.error}`,
199
+ if (result.delivery !== 'native') {
200
+ if (late && message.messageId) rejectCompletedDelivery(message.messageId, result.error);
201
+ if (message.messageId) await sendDeliveryReceipt(client, message.messageId, 'rejected',
202
+ result.error, target.sessionId, peerPublicKey);
203
+ }
204
+ await say(late || result.delivery === 'native' ? `The run stopped: ${result.error}`
205
+ : `Could not deliver the message: ${result.error}`,
203
206
  target.sessionId, true);
204
207
  }
205
208
  };
206
209
  const early = await acceptWhenStarted(run, (result) => settle(result, true));
207
210
  if (early === "started") return "accepted";
208
211
  await settle(early, false);
209
- return early.ok ? "accepted" : "rejected";
212
+ return early.ok || early.delivery === 'native' ? "accepted" : "rejected";
210
213
  }
@@ -128,17 +128,20 @@ async function publishMonitorTick(input: {
128
128
  if (!leadership.acquire()) return { lastHistoryPublishedAt };
129
129
  await client.send(approvalsStatus(), "phone", { ttlMs: INTERVAL_MS * 3, reliable: false });
130
130
  const initial = lastHistoryPublishedAt === 0;
131
+ if (initial && !resumedHeads.has(client)) {
132
+ const saved = publishedCatalog(client);
133
+ if (saved) await client.send(saved, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false });
134
+ if (!input.active()) return { lastHistoryPublishedAt };
135
+ resumedHeads.add(client);
136
+ void resumePublishedChats(client,
137
+ (session, saved) => publishSessionEvents(client, session.sessionId, saved)).catch(() => {});
138
+ }
131
139
  const includeHistory = forceHistory || (!initial && Date.now() - lastHistoryPublishedAt >= HISTORY_INTERVAL_MS);
132
140
  const snapshot = await monitorSnapshot({ includeHistory, forceHistory, subscriptions, history });
133
141
  if (!input.active()) return { lastHistoryPublishedAt };
134
142
  const status = decorateTaskOriginCatalog(snapshot, client.room);
135
143
  rememberPublishedCatalog(client, status);
136
144
  await client.send(status, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false });
137
- if (initial && !resumedHeads.has(client)) {
138
- resumedHeads.add(client);
139
- void resumePublishedChats(client,
140
- (session, saved) => publishSessionEvents(client, session.sessionId, saved)).catch(() => {});
141
- }
142
145
  // Provider review is independent of task visibility; a hung hook RPC must
143
146
  // never hold the current catalog behind its discovery timeout.
144
147
  void refreshCodexHooks().catch(() => {});
@@ -0,0 +1,20 @@
1
+ # Grok response admission
2
+
3
+ [`index.ts`](index.ts) reads Grok Build's `streaming-json` response contract.
4
+ Actual text, thinking, or a started tool call confirms that the prompt reached
5
+ execution. Startup metadata alone does not establish admission. Native error
6
+ messages remain failures and metadata is never returned as answer text.
7
+
8
+ Opening a session has a 60-second response-activity deadline, separate from the
9
+ long execution budget. A stalled opening stops only the process group GrantTap
10
+ started and asks the person to inspect the native task before retrying. Once
11
+ response activity is witnessed, a later timeout or run failure cannot rewrite
12
+ the accepted message as a rejected delivery. Execution failure still reaches
13
+ the chat. This does not grant tools, authenticate MCP, or alter Grok policy.
14
+
15
+ Behavior tests live in `../tests/grok-response.test.ts` and
16
+ `../tests/admission-deadline.test.ts`; durable receipt tests live in
17
+ `../../monitor/message-delivery/tests/run-outcome.test.ts`.
18
+
19
+ License: this module is distributed under the GrantTap Commercial Source License
20
+ in the repository-root `LICENSE` file.
@@ -0,0 +1,48 @@
1
+ import type { ReplyResult } from '../payload/types';
2
+ import type { ProviderAdmission } from '../process';
3
+
4
+ function events(stdout: string): Record<string, unknown>[] {
5
+ return stdout.split('\n').flatMap(line => {
6
+ try {
7
+ const value: unknown = JSON.parse(line);
8
+ return value && typeof value === 'object' && !Array.isArray(value)
9
+ ? [value as Record<string, unknown>] : [];
10
+ } catch { return []; }
11
+ });
12
+ }
13
+
14
+ /** Response activity proves admission; startup lists and diagnostic text do not. */
15
+ export function grokAccepted(stdout: string): boolean {
16
+ return events(stdout).some(event => {
17
+ if (['text', 'thought'].includes(String(event.type))) {
18
+ return typeof event.data === 'string' && !!event.data.trim();
19
+ }
20
+ if (event.type === 'result') return typeof event.result === 'string' && !!event.result.trim();
21
+ return event.type === 'tool_call' && event.status === 'in_progress'
22
+ && typeof event.toolCallId === 'string' && !!event.toolCallId;
23
+ });
24
+ }
25
+
26
+ /** Opening a native session has a separate limit from executing its long turn. */
27
+ export const grokAdmission: ProviderAdmission = {
28
+ confirmed: grokAccepted,
29
+ withinMs: 60_000,
30
+ timeoutMessage: 'Grok did not confirm receipt within 60s. Open the task before retrying.',
31
+ };
32
+
33
+ export function parseGrok(stdout: string, fallback: string): ReplyResult {
34
+ let text = '';
35
+ let sessionId = fallback;
36
+ for (const event of events(stdout)) {
37
+ if (event.type === 'text' && typeof event.data === 'string') text += event.data;
38
+ if (event.type === 'result' && typeof event.result === 'string') text = event.result;
39
+ if (typeof event.sessionId === 'string') sessionId = event.sessionId;
40
+ if (typeof event.session_id === 'string') sessionId = event.session_id;
41
+ const error = typeof event.message === 'string' ? event.message : event.error;
42
+ if (event.type === 'error' && typeof error === 'string') {
43
+ return { ok: false, error: error.slice(0, 500) };
44
+ }
45
+ }
46
+ return text.trim() ? { ok: true, text: text.trim().slice(0, 4_000), sessionId }
47
+ : { ok: false, error: 'Grok Build returned an empty response.' };
48
+ }
@@ -72,6 +72,12 @@ export function runningProcessCount(key: string): number {
72
72
  return running.get(key)?.size ?? 0;
73
73
  }
74
74
 
75
+ export type ProviderAdmission = {
76
+ confirmed: (stdout: string) => boolean;
77
+ withinMs: number;
78
+ timeoutMessage: string;
79
+ };
80
+
75
81
  export function runProcess(
76
82
  command: string,
77
83
  args: string[],
@@ -80,6 +86,7 @@ export function runProcess(
80
86
  parse: (stdout: string) => ReplyResult,
81
87
  stdin?: string,
82
88
  key?: string,
89
+ admission?: ProviderAdmission,
83
90
  ): Promise<ReplyResult> {
84
91
  return new Promise((resolve) => {
85
92
  let child: ChildProcess;
@@ -104,14 +111,20 @@ export function runProcess(
104
111
  if (done) return;
105
112
  done = true;
106
113
  clearTimeout(timer);
114
+ clearTimeout(startupTimer);
107
115
  untrack();
108
- resolve(result);
116
+ resolve(!result.ok && admission?.confirmed(stdout) ? { ...result, delivery: 'native' } : result);
109
117
  };
110
118
  const timer = setTimeout(() => {
111
119
  if (done) return;
112
120
  signalOwnedProcess(child, "SIGKILL");
113
121
  finish({ ok: false, error: `${command} did not respond within ${Math.round(timeoutMs / 1000)}s.` });
114
122
  }, timeoutMs);
123
+ const startupTimer = admission ? setTimeout(() => {
124
+ if (done || admission.confirmed(stdout)) return;
125
+ signalOwnedProcess(child, "SIGKILL");
126
+ finish({ ok: false, error: admission.timeoutMessage });
127
+ }, admission.withinMs) : undefined;
115
128
  child.stdout?.on("data", (chunk) => { stdout += chunk; });
116
129
  child.stderr?.on("data", (chunk) => { stderr += chunk; });
117
130
  child.on("error", (error) => {
@@ -1,3 +1,4 @@
1
+ import { grokAdmission, parseGrok } from "../grok-response";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import type { SessionInfo } from "../../../../../packages/protocol/schema";
3
4
  import { runProcess } from ".";
@@ -11,33 +12,6 @@ function grokBin(): string {
11
12
  return resolveGrokBinary();
12
13
  }
13
14
 
14
- function parseGrok(stdout: string, fallback: string): ReplyResult {
15
- let text = "";
16
- let sessionId = fallback;
17
- for (const line of stdout.split("\n")) {
18
- if (!line.trim()) continue;
19
- try {
20
- const value = JSON.parse(line) as {
21
- type?: string; data?: unknown; sessionId?: unknown; session_id?: unknown;
22
- result?: unknown; error?: unknown;
23
- };
24
- if (value.type === "text" && typeof value.data === "string") text += value.data;
25
- if (value.type === "result" && typeof value.result === "string") text = value.result;
26
- if (typeof value.sessionId === "string") sessionId = value.sessionId;
27
- if (typeof value.session_id === "string") sessionId = value.session_id;
28
- if (value.type === "error" && typeof value.error === "string") {
29
- return { ok: false, error: value.error.slice(0, 500) };
30
- }
31
- } catch {
32
- // Ignore diagnostic lines outside Grok's streaming JSON contract.
33
- }
34
- }
35
- const visible = text.trim() || stdout.trim();
36
- return visible
37
- ? { ok: true, text: visible.slice(0, 4_000), sessionId }
38
- : { ok: false, error: "Grok Build returned an empty response." };
39
- }
40
-
41
15
  export function runCursorNew(
42
16
  text: string, cwd: string, timeoutMs: number, model?: string, operationKey?: string, effort?: string,
43
17
  ): Promise<ReplyResult> {
@@ -62,7 +36,7 @@ export function runGrokNew(
62
36
  "-p", text, "--output-format", "streaming-json",
63
37
  ];
64
38
  return runProcess(grokBin(), args, cwd, timeoutMs, (stdout) => parseGrok(stdout, sessionId),
65
- undefined, operationKey);
39
+ undefined, operationKey, grokAdmission);
66
40
  }
67
41
 
68
42
  export function runGrokResume(
@@ -79,6 +53,6 @@ export function runGrokResume(
79
53
  return runProcess(
80
54
  grokBin(), args, session.cwd, timeoutMs,
81
55
  (stdout) => parseGrok(stdout, session.sessionId),
82
- undefined, session.sessionId,
56
+ undefined, session.sessionId, grokAdmission,
83
57
  );
84
58
  }
@@ -9,10 +9,11 @@ unreadable merely because hundreds of newer logs appeared.
9
9
  The private `session-catalog-cache.json` contains metadata and validated native
10
10
  source locations. It excludes messages, capability descriptions, and credentials.
11
11
  The room and provider roots bind its scope. On restart the primary monitor sends
12
- a fresh live catalog first, then resumes the saved Now chats' native heads under
13
- their independent task keys in the background. Closed-history discovery runs
14
- separately from that first live publication. It does not republish an old catalog
15
- as fresh. The phone continues its durable historical backfill independently.
12
+ the saved catalog with its original timestamp before starting fresh discovery,
13
+ and resumes the saved Now chats' native heads under their independent task keys
14
+ in the background. Fresh discovery replaces that saved state. Closed-history
15
+ discovery runs separately from the first live publication. The phone continues
16
+ its durable historical backfill independently.
16
17
 
17
18
  Tests live in `tests/`. This runtime module uses the repository GrantTap Commercial Source License.
18
19
 
@@ -1,19 +1,25 @@
1
1
  import type { RelayClient } from "../../../../packages/core/relay-client";
2
2
  import type { SessionsStatus } from "../../../../packages/protocol/schema";
3
3
 
4
- type HeadRun = { running: boolean; closed: boolean };
4
+ type HeadRun = { running: boolean; closed: boolean; cursor: number };
5
5
  const runs = new WeakMap<RelayClient, HeadRun>();
6
6
 
7
7
  /** Transcript transfers must never occupy the fresh catalog's publication slot. */
8
8
  export function publishCatalogHeads(client: RelayClient, status: SessionsStatus,
9
9
  subscriptions: ReadonlySet<string>,
10
10
  publish: (client: RelayClient, sessionId: string, status: SessionsStatus) => Promise<unknown>): void {
11
- const state = runs.get(client) ?? { running: false, closed: false };
11
+ const state = runs.get(client) ?? { running: false, closed: false, cursor: 0 };
12
12
  runs.set(client, state);
13
- if (state.running || state.closed || subscriptions.size === 0) return;
13
+ if (state.running || state.closed) return;
14
+ const candidates = [...new Set([...subscriptions,
15
+ ...(client.supportsRetainedState ? status.sessions.map(session => session.sessionId) : [])])];
16
+ if (!candidates.length) return;
17
+ const count = Math.min(32, candidates.length);
18
+ const selected = Array.from({ length: count }, (_, offset) => candidates[(state.cursor + offset) % candidates.length]!);
19
+ state.cursor = (state.cursor + count) % candidates.length;
14
20
  state.running = true;
15
21
  void (async () => {
16
- for (const sessionId of [...subscriptions]) {
22
+ for (const sessionId of selected) {
17
23
  await new Promise<void>(resolve => setImmediate(resolve));
18
24
  if (state.closed || !client.isConnected) break;
19
25
  await publish(client, sessionId, status).catch(() => {});
@@ -18,6 +18,14 @@ the child snapshot. Root chat pagination remains in `TranscriptHistory`.
18
18
  Feature tests live in `tests/`; the reusable multi-provider conversation harness
19
19
  lives in the repository `tests/thread/` directory.
20
20
 
21
+ ## Grok Build history
22
+
23
+ Grok discovery reads the local session summaries and visible conversation history.
24
+ Both role-based records and current typed user/assistant records are supported.
25
+ Typed user records must have a valid prompt index. Environment preambles, synthetic
26
+ reminders, system messages, and hidden reasoning are excluded from the timeline.
27
+ Session continuation uses the installed Grok CLI and its existing authentication.
28
+
21
29
  ## License
22
30
 
23
31
  Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
@@ -38,6 +38,9 @@ import {
38
38
  toRemoteCapabilityUsageEvent,
39
39
  } from "./telemetry";
40
40
  import { createCapabilityTotals } from "./support/capability-totals";
41
+ import { createExecutionSummaries, budgetExecutionSummaries } from './telemetry/execution-summaries';
42
+ import { readCodexCapabilityHistory, type NativeCapabilityEvidence } from './native-capability-history';
43
+ import { recordedClaudeUsage } from './scan/claude';
41
44
 
42
45
  export { MAX_ACTIVITY_ENTRIES, MAX_ACTIVITY_TEXT, TOKEN_WINDOW_HOURS };
43
46
  export { nativeTranscriptRoots, nativeTranscriptSource, restoreNativeTranscriptSource } from "./native-source";
@@ -115,11 +118,13 @@ export function scanSessionActivity(session: SessionInfo): SessionActivity {
115
118
  */
116
119
  export function scanCapabilityUsage(
117
120
  sessions: SessionInfo[] = [...scanSessions().sessions, ...scanSessionHistory()],
121
+ native?: NativeCapabilityEvidence,
118
122
  ): CapabilityUsageStatus {
119
123
  const candidates: RemoteCapabilityUsageEvent[] = [];
120
124
  // Totals count every observation as it streams past; the candidate list is
121
125
  // trimmed to a byte budget and can never answer for a whole period.
122
126
  const totals = createCapabilityTotals();
127
+ const summaries = createExecutionSummaries();
123
128
  const seenSessions = new Set<string>();
124
129
  for (const session of sessions) {
125
130
  const nativeScope = `${session.agent}\0${session.sessionId}`;
@@ -131,19 +136,35 @@ export function scanCapabilityUsage(
131
136
  : session.agent === "cursor" ? cursorCapabilityUsage(session)
132
137
  : session.agent === "grok" ? grokCapabilityUsage(session)
133
138
  : [];
134
- for (const observation of observations) {
135
- const converted = toRemoteCapabilityUsageEvent(observation);
136
- if (!converted) continue;
139
+ const recorded = session.agent === 'codex' && native?.scopes.includes(session.sessionId);
140
+ const claudeRecorded = session.agent === 'claude' ? recordedClaudeUsage(observations) : undefined;
141
+ const events = observations.map(toRemoteCapabilityUsageEvent).filter(
142
+ (event): event is RemoteCapabilityUsageEvent => event != null
143
+ && !(recorded && event.kind === 'mcp'));
144
+ if (recorded) events.push(...native!.events.filter(event => event.sessionId === session.sessionId));
145
+ for (const converted of events) {
137
146
  const event = { ...converted, agent: session.agent };
138
- totals.add(event);
147
+ if (!claudeRecorded || event.kind === 'cli') {
148
+ totals.add(event);
149
+ summaries.add(event, recorded && event.kind === 'mcp' ? 'native-history' : 'transcript');
150
+ }
139
151
  rememberCapabilityUsageCandidate(candidates, event);
140
152
  }
153
+ for (const summary of claudeRecorded?.summaries ?? []) summaries.merge(summary);
154
+ for (const total of claudeRecorded?.totals ?? []) totals.merge(total);
141
155
  }
142
- const envelope = { type: "capability.usage.status" as const,
156
+ const metadata = { type: "capability.usage.status" as const,
143
157
  totals: totals.rows(), generatedAt: Date.now() };
158
+ const executionSummaries = budgetExecutionSummaries(summaries.rows(), metadata);
159
+ const envelope = { ...metadata, ...(executionSummaries.length ? { executionSummaries } : {}) };
144
160
  return { ...envelope, events: limitCapabilityUsageEvents(candidates, envelope) };
145
161
  }
146
162
 
163
+ /** Native metadata enrichment runs off the relay event loop. */
164
+ export async function scanRecordedCapabilityUsage(sessions: SessionInfo[]): Promise<CapabilityUsageStatus> {
165
+ return scanCapabilityUsage(sessions, await readCodexCapabilityHistory(sessions));
166
+ }
167
+
147
168
  /** Bind every local observation to the authenticated relay room before publish. */
148
169
  export function scopeCapabilityUsageToRoom(
149
170
  status: CapabilityUsageStatus,
@@ -0,0 +1,20 @@
1
+ # Native capability history
2
+
3
+ `index.ts` reads Codex's local history projection through a bounded worker.
4
+ Native MCP execution records provide call identity, outcome, and duration even
5
+ when the call happened through Code Mode and the transcript preview omits it.
6
+ The database is opened read only. Native projection watermarks and the provider's
7
+ updated-page index restrict warm reads to changed calls; a completion replaces
8
+ its original call rather than adding another one. Queries run in a worker with
9
+ a bounded deadline and never occupy the relay event loop. Missing or incompatible projections remain
10
+ unknown; the existing transcript reader remains a compatibility fallback.
11
+
12
+ Only requested executions and their recorded children are queried. The worker
13
+ returns metadata, never prompts, arguments, or tool results. Its thirty-day,
14
+ 20,000-record bound and per-result size bound make summaries observed evidence,
15
+ not a claim of complete lifetime usage. The recent-call list and execution
16
+ summaries are bounded independently. File paths follow the configured Codex
17
+ root and contain no machine-specific fallback.
18
+
19
+ Tests use a real SQLite history projection and reopen it after the writer closes.
20
+ This module uses the repository GrantTap Commercial Source License.
@@ -0,0 +1,31 @@
1
+ import type { SessionInfo } from '../../../../../packages/protocol/schema';
2
+ import type { CapabilityObservation } from '../telemetry';
3
+ import { ts } from '../support/common';
4
+
5
+ /** Only host-authored execution records establish inner Code Mode calls. */
6
+ export function completedCodexMcp(row: any, session: SessionInfo): CapabilityObservation | undefined {
7
+ const payload = row?.payload;
8
+ if (row?.type !== 'event_msg' || payload?.type !== 'item_completed'
9
+ || (payload.thread_id != null && payload.thread_id !== session.sessionId)) return;
10
+ const item = payload.item;
11
+ if (!['McpToolCall', 'mcpToolCall'].includes(item?.type)) return;
12
+ if (![item.id, item.server, item.tool].every(x => typeof x === 'string' && x.trim() && x.length <= 240)) return;
13
+ const start = payload.started_at_ms ?? ts(row.timestamp) ?? session.lastActivityAt;
14
+ const elapsed = item.durationMs ?? (item.duration && Number.isFinite(item.duration.secs)
15
+ && Number.isFinite(item.duration.nanos) ? item.duration.secs * 1000 + item.duration.nanos / 1e6
16
+ : payload.completed_at_ms != null ? payload.completed_at_ms - start : undefined);
17
+ const outcome = item.status === 'failed' || item.result?.isError === true || item.result?.is_error === true
18
+ ? 'error' : ['cancelled', 'canceled'].includes(item.status) ? 'cancelled'
19
+ : item.status === 'completed' ? 'success' : 'unknown';
20
+ return { sourceId: `${session.sessionId}:${item.id}`, sessionId: session.sessionId,
21
+ toolName: `mcp__${item.server}__${item.tool}`, mcpServer: item.server, createdAt: start,
22
+ outcome, ...(Number.isFinite(elapsed) && elapsed >= 0
23
+ ? { durationMs: Math.min(3600000, Math.round(elapsed)) } : {}) };
24
+ }
25
+
26
+ export function recordedCodexToolName(payload: any): string {
27
+ const name = String(payload.name ?? payload.type);
28
+ const namespace = payload.namespace;
29
+ return typeof namespace === 'string' && /^mcp__[A-Za-z0-9_-]+$/.test(namespace)
30
+ ? `${namespace}__${name}` : name;
31
+ }
@@ -0,0 +1,67 @@
1
+ import type { CapabilityExecutionSummary, RemoteCapabilityUsageEvent, SessionInfo } from '../../../../../packages/protocol/schema';
2
+ import { Worker } from 'node:worker_threads';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { codexSessionsRoot } from '../support/common';
6
+ export { completedCodexMcp, recordedCodexToolName } from './completed-item';
7
+ export type NativeCapabilityEvidence = {
8
+ scopes: string[];
9
+ events: RemoteCapabilityUsageEvent[];
10
+ summaries: CapabilityExecutionSummary[];
11
+ };
12
+
13
+ const empty = (): NativeCapabilityEvidence => ({ scopes: [], events: [], summaries: [] });
14
+ const cache = new Map<string, { at: number; value: Promise<NativeCapabilityEvidence> }>();
15
+ type Cursor = { threadId: string; watermark: number; events: RemoteCapabilityUsageEvent[] };
16
+ const cursors = new Map<string, Cursor[]>();
17
+
18
+ /** Query only bounded call metadata in a worker; native conversation contents stay local. */
19
+ export function readCodexCapabilityHistory(
20
+ sessions: SessionInfo[], root = process.env.GRANTTAP_CODEX_DIR ?? process.env.NODVOX_CODEX_DIR
21
+ ?? process.env.CODEX_HOME ?? dirname(codexSessionsRoot()),
22
+ ): Promise<NativeCapabilityEvidence> {
23
+ const database = join(root, 'thread_history_1.sqlite');
24
+ const owners = new Map<string, string>();
25
+ for (const session of sessions.filter(s => s.agent === 'codex')) {
26
+ owners.set(session.sessionId, session.sessionId);
27
+ for (const child of session.childThreads ?? []) {
28
+ if (!owners.has(child.threadId)) owners.set(child.threadId, session.sessionId);
29
+ }
30
+ }
31
+ const bindings = [...owners].slice(0, 512);
32
+ if (!bindings.length || !existsSync(database)) return Promise.resolve(empty());
33
+ const key = JSON.stringify([database, bindings]);
34
+ const previous = cache.get(key);
35
+ if (previous && Date.now() - previous.at < 30_000) return previous.value;
36
+ const value = readWorker(database, bindings);
37
+ cache.set(key, { at: Date.now(), value });
38
+ while (cache.size > 4) cache.delete(cache.keys().next().value!);
39
+ return value;
40
+ }
41
+
42
+ function readWorker(database: string, bindings: [string, string][]): Promise<NativeCapabilityEvidence> {
43
+ return new Promise(resolve => {
44
+ const requested = new Set(bindings.map(([thread]) => thread));
45
+ const worker = new Worker(new URL('./reader.js', import.meta.url), { execArgv: [],
46
+ workerData: { database, bindings, now: Date.now(),
47
+ previous: (cursors.get(database) ?? []).filter(cursor => requested.has(cursor.threadId)) },
48
+ resourceLimits: { maxOldGenerationSizeMb: 64 } });
49
+ let evidence = empty();
50
+ const timer = setTimeout(() => { void worker.terminate(); }, 45_000);
51
+ worker.once('message', (result: NativeCapabilityEvidence & { cursors?: Cursor[] }) => {
52
+ const { cursors: next, ...publicEvidence } = result;
53
+ if (next) {
54
+ const merged = [...next, ...(cursors.get(database) ?? []).filter(cursor => !requested.has(cursor.threadId))];
55
+ let count = 0;
56
+ cursors.set(database, merged.filter(cursor => {
57
+ count += cursor.events.length;
58
+ return count <= 20000;
59
+ }).slice(0, 512));
60
+ while (cursors.size > 4) cursors.delete(cursors.keys().next().value!);
61
+ }
62
+ evidence = publicEvidence;
63
+ });
64
+ worker.once('error', () => { evidence = empty(); });
65
+ worker.once('exit', code => { clearTimeout(timer); resolve(code === 0 ? evidence : empty()); });
66
+ });
67
+ }