granttap-mcp 0.8.86 → 0.8.87

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 (37) hide show
  1. package/README.md +4 -0
  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/support/runtime.ts +8 -5
  5. package/apps/bridge/src/session-catalog/README.md +5 -4
  6. package/apps/bridge/src/session-catalog/publish-heads.ts +10 -4
  7. package/apps/bridge/src/sessions/README.md +8 -0
  8. package/apps/bridge/src/sessions/index.ts +26 -5
  9. package/apps/bridge/src/sessions/native-capability-history/README.md +20 -0
  10. package/apps/bridge/src/sessions/native-capability-history/completed-item.ts +31 -0
  11. package/apps/bridge/src/sessions/native-capability-history/index.ts +67 -0
  12. package/apps/bridge/src/sessions/native-capability-history/reader.js +97 -0
  13. package/apps/bridge/src/sessions/scan/claude/activity.ts +10 -3
  14. package/apps/bridge/src/sessions/scan/claude/index.ts +1 -0
  15. package/apps/bridge/src/sessions/scan/claude/parse.ts +6 -2
  16. package/apps/bridge/src/sessions/scan/claude/recorded-usage/README.md +17 -0
  17. package/apps/bridge/src/sessions/scan/claude/recorded-usage/index.ts +60 -0
  18. package/apps/bridge/src/sessions/scan/codex/activity.ts +15 -5
  19. package/apps/bridge/src/sessions/scan/grok.ts +12 -2
  20. package/apps/bridge/src/sessions/support/capability-totals.ts +0 -0
  21. package/apps/bridge/src/sessions/telemetry/execution-summaries.ts +56 -0
  22. package/apps/mcp/src/desktop/task-activity-worker.ts +4 -4
  23. package/apps/mcp/src/http/README.md +15 -0
  24. package/apps/mcp/src/http/http-server.ts +1 -3
  25. package/cursor-plugin/README.md +1 -1
  26. package/cursor-plugin/mcp.json +1 -1
  27. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  28. package/package.json +1 -1
  29. package/packages/core/relay-client-types.ts +6 -0
  30. package/packages/core/relay-client.ts +14 -19
  31. package/packages/core/relay-envelope.ts +7 -3
  32. package/packages/core/relay-inbound/index.ts +2 -0
  33. package/packages/core/relay-inbound/wait-for.ts +19 -0
  34. package/packages/core/retained-state/README.md +23 -0
  35. package/packages/core/retained-state/index.ts +25 -0
  36. package/packages/protocol/messages/capabilities.ts +17 -0
  37. package/packages/protocol/schema.ts +4 -0
package/README.md CHANGED
@@ -843,3 +843,7 @@ sustained structured work in a different checkout without interpreting chat
843
843
  titles. Task identity and its original coordination/access scope survive.
844
844
 
845
845
  See [repository observations](apps/bridge/src/mesh/repository-details/README.md).
846
+
847
+ 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.
848
+
849
+ 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; });
@@ -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(() => {});
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+
4
+ const LIMIT = 20000;
5
+ const MAX_DURATION = 60 * 60_000;
6
+ const validName = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 160;
7
+
8
+ function outcome(row) {
9
+ if (row.status === 'failed' || row.resultError === 1) return 'error';
10
+ if (row.status === 'cancelled' || row.status === 'canceled') return 'cancelled';
11
+ return row.status === 'completed' ? 'success' : 'unknown';
12
+ }
13
+
14
+ function observe(row, sessionId, now) {
15
+ if (!validName(row.server) || !validName(row.tool) || typeof row.item_id !== 'string') return;
16
+ const createdAt = Number(row.started_at_ms ?? row.created_at_ms);
17
+ if (!Number.isFinite(createdAt) || createdAt <= 0 || createdAt > now + 300000) return;
18
+ const duration = row.durationMs ?? (row.completed_at_ms != null && row.started_at_ms != null
19
+ ? row.completed_at_ms - row.started_at_ms : undefined);
20
+ return { sourceId: `${row.thread_id}:${row.item_id}`.slice(0, 512), sessionId,
21
+ agent: 'codex', kind: 'mcp', name: row.server.trim(),
22
+ toolName: `mcp__${row.server.trim()}__${row.tool.trim()}`.slice(0, 240), createdAt,
23
+ outcome: outcome(row), ...(Number.isFinite(duration) && duration >= 0
24
+ ? { durationMs: Math.min(MAX_DURATION, Math.round(duration)) } : {}) };
25
+ }
26
+
27
+ function readThread(db, { threadId, previous, watermark, now, limit }) {
28
+ const valid = previous && previous.watermark <= watermark;
29
+ const cached = new Map((valid ? previous.events : []).filter(event => event.createdAt >= now - 30 * 86400000)
30
+ .map(event => [event.sourceId, event]));
31
+ if (valid && previous.watermark === watermark) return [...cached.values()].slice(0, limit);
32
+ // The provider's own updated-page index bounds subsequent reads to new or
33
+ // completed calls. Never add an index or write to the provider database.
34
+ const rows = db.prepare(`SELECT thread_id,item_id,created_at_ms,started_at_ms,completed_at_ms,
35
+ json_extract(item_json,'$.server') AS server,json_extract(item_json,'$.tool') AS tool,
36
+ json_extract(item_json,'$.status') AS status,json_extract(item_json,'$.durationMs') AS durationMs,
37
+ json_extract(item_json,'$.result.isError') AS resultError
38
+ FROM thread_items WHERE thread_id=? AND updated_at_ordinal>=? AND item_type='mcpToolCall'
39
+ AND created_at_ms>=? AND length(CAST(item_json AS BLOB))<=262144 AND json_valid(item_json)
40
+ ORDER BY updated_at_ordinal DESC LIMIT ${limit}`)
41
+ .all(threadId, valid ? previous.watermark : 0, now - 30 * 86400000);
42
+ for (const row of rows) {
43
+ const event = observe(row, threadId, now);
44
+ if (event) cached.set(event.sourceId, event);
45
+ }
46
+ return [...cached.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
47
+ }
48
+
49
+ function readHistory({ database, bindings, now, previous = [] }) {
50
+ const db = new DatabaseSync(database, { readOnly: true });
51
+ try {
52
+ db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=30;');
53
+ const owners = new Map(bindings);
54
+ const ids = [...owners.keys()];
55
+ const placeholders = ids.map(() => '?').join(',');
56
+ const projections = db.prepare(`SELECT thread_id,next_rollout_ordinal FROM thread_history_projection_state
57
+ WHERE thread_id IN (${placeholders})`).all(...ids);
58
+ const scopes = [...new Set(projections.map(row => owners.get(row.thread_id)))];
59
+ // Large tool results are excluded before JSON extraction. Neither prompts,
60
+ // tool arguments nor result contents cross the worker boundary.
61
+ const old = new Map(previous.map(cursor => [cursor.threadId, cursor]));
62
+ const next = [];
63
+ let remaining = LIMIT;
64
+ for (const row of projections.sort((a, b) => ids.indexOf(a.thread_id) - ids.indexOf(b.thread_id))) {
65
+ if (remaining <= 0) break;
66
+ const events = readThread(db, { threadId: row.thread_id, previous: old.get(row.thread_id),
67
+ watermark: row.next_rollout_ordinal, now, limit: remaining });
68
+ next.push({ threadId: row.thread_id, watermark: row.next_rollout_ordinal, events });
69
+ remaining -= events.length;
70
+ }
71
+ const events = next.flatMap(cursor => cursor.events.map(event => ({ ...event,
72
+ sessionId: owners.get(cursor.threadId) }))).sort((a, b) => b.createdAt - a.createdAt).slice(0, LIMIT);
73
+ const summaries = new Map();
74
+ for (const event of events) {
75
+ const sessionId = event.sessionId;
76
+ const key = JSON.stringify([sessionId, event.name]);
77
+ const summary = summaries.get(key) ?? { sessionId, agent: 'codex', kind: 'mcp',
78
+ name: event.name, count: 0, failures: 0, cancelled: 0, lastUsedAt: 0,
79
+ source: 'native-history' };
80
+ summary.count++;
81
+ summary.failures += event.outcome === 'error' ? 1 : 0;
82
+ summary.cancelled += event.outcome === 'cancelled' ? 1 : 0;
83
+ summary.lastUsedAt = Math.max(summary.lastUsedAt, event.createdAt);
84
+ if (event.durationMs != null) {
85
+ summary.durationMs = (summary.durationMs ?? 0) + event.durationMs;
86
+ summary.durationCount = (summary.durationCount ?? 0) + 1;
87
+ }
88
+ summaries.set(key, summary);
89
+ }
90
+ return { scopes, events, cursors: next, summaries: [...summaries.values()]
91
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt).slice(0, 128) };
92
+ } finally { db.close(); }
93
+ }
94
+
95
+ try { parentPort.postMessage(readHistory(workerData)); }
96
+ catch { parentPort.postMessage({ scopes: [], events: [], summaries: [] }); }
97
+ parentPort.close();
@@ -1,4 +1,5 @@
1
1
  import { claudeMessageIdentity } from "./message-identity";
2
+ import { createClaudeUsageRecorder } from './recorded-usage';
2
3
  import { readFileSync, statSync } from "node:fs";
3
4
  import type { ActivityEntry, ChildThreadInfo, SessionInfo } from "../../../../../../packages/protocol/schema";
4
5
  import { safeParse, ts } from "../../support/common";
@@ -60,6 +61,11 @@ export function claudeCapabilityUsage(
60
61
  if (!lines) return [];
61
62
  const pending = new Map<string, PendingCapabilityTool>();
62
63
  const out: CapabilityObservation[] = [];
64
+ const recorder = createClaudeUsageRecorder();
65
+ const remember = (observation: CapabilityObservation) => {
66
+ recorder.remember(observation);
67
+ rememberCapabilityObservation(out, observation);
68
+ };
63
69
 
64
70
  let commandOrdinal = 0;
65
71
  for (const line of lines) {
@@ -71,7 +77,7 @@ export function claudeCapabilityUsage(
71
77
  const command = slashCommandSkill(row);
72
78
  if (command) {
73
79
  commandOrdinal += 1;
74
- rememberCapabilityObservation(out, {
80
+ remember({
75
81
  sourceId: `${sourceThreadId}:command:${typeof row.uuid === "string" ? row.uuid : commandOrdinal}`,
76
82
  sessionId: session.sessionId,
77
83
  toolName: `/${command}`,
@@ -115,15 +121,16 @@ export function claudeCapabilityUsage(
115
121
  errorClass: block.is_error === true ? "tool_result" : undefined,
116
122
  }, "claude") ??
117
123
  pendingCapabilityObservation(item);
118
- if (observation) rememberCapabilityObservation(out, observation);
124
+ if (observation) remember(observation);
119
125
  pending.delete(block.tool_use_id);
120
126
  }
121
127
  }
122
128
 
123
129
  for (const item of pending.values()) {
124
130
  const observation = pendingCapabilityObservation(item);
125
- if (observation) rememberCapabilityObservation(out, observation);
131
+ if (observation) remember(observation);
126
132
  }
133
+ recorder.finish(out);
127
134
  return out;
128
135
  }
129
136
 
@@ -1,3 +1,4 @@
1
1
  export * from "./shared";
2
2
  export * from "./scan";
3
3
  export * from "./activity";
4
+ export { recordedClaudeUsage } from './recorded-usage';
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
+ import { mergeRecordedClaudeUsage, recordedClaudeUsageIsCurrent } from './recorded-usage';
2
3
  import { basename } from "node:path";
3
4
  import type { ChildThreadInfo, SessionInfo } from "../../../../../../packages/protocol/schema";
4
5
  import { aggregateChildThreads, childTitle } from "../../support/child-threads";
@@ -124,7 +125,8 @@ export function parseClaudeFile(file: string): {
124
125
  cached &&
125
126
  cached.mtimeMs === fileStat.mtimeMs &&
126
127
  cached.size === fileStat.size &&
127
- cached.childFingerprint === childFingerprint
128
+ cached.childFingerprint === childFingerprint &&
129
+ recordedClaudeUsageIsCurrent(cached.observations)
128
130
  ) {
129
131
  const session = {
130
132
  ...cached.session,
@@ -182,9 +184,11 @@ export function parseClaudeFile(file: string): {
182
184
  if (!childLines) continue;
183
185
  const child = claudeChildSummary(childPath, state.sessionId, childLines);
184
186
  childThreads.push(child);
185
- for (const observation of claudeCapabilityUsage(ownSession, childLines, child.threadId)) {
187
+ const childObservations = claudeCapabilityUsage(ownSession, childLines, child.threadId);
188
+ for (const observation of childObservations) {
186
189
  rememberCapabilityObservation(observations, observation);
187
190
  }
191
+ mergeRecordedClaudeUsage(observations, childObservations);
188
192
  }
189
193
  const session = aggregateChildThreads(ownSession, childThreads);
190
194
  claudeSummaryCache.set(file, {
@@ -0,0 +1,17 @@
1
+ # Recorded Claude capability usage
2
+
3
+ Public entry point: [`index.ts`](index.ts).
4
+
5
+ The Claude transcript parser records confirmed MCP and skill observations before
6
+ the recent detail cache is trimmed. It keeps at most 20,000 metadata observations
7
+ from the last 30 days while parsing, then retains compact execution and period
8
+ aggregates keyed to the existing transcript cache. Inputs and result bodies are
9
+ not retained by this module. Child transcript aggregates remain attributed to
10
+ their parent execution. Missing duration or resource evidence stays unknown.
11
+
12
+ Tests live in [`tests/recorded-usage.test.ts`](tests/recorded-usage.test.ts).
13
+ The repository quality and public package boundaries apply to this module.
14
+
15
+ ## License
16
+
17
+ This module is covered by the [GrantTap Commercial Source License](../../../../../../../LICENSE).
@@ -0,0 +1,60 @@
1
+ import type { CapabilityExecutionSummary, CapabilityUsageTotal, RemoteCapabilityUsageEvent } from '../../../../../../../packages/protocol/schema';
2
+ import { CAPABILITY_TOTAL_WINDOWS, createCapabilityTotals } from '../../../support/capability-totals';
3
+ import { createExecutionSummaries } from '../../../telemetry/execution-summaries';
4
+ import { toRemoteCapabilityUsageEvent, type CapabilityObservation } from '../../../telemetry';
5
+
6
+ type RecordedClaudeUsage = { summaries: CapabilityExecutionSummary[]; totals: CapabilityUsageTotal[]; nextExpiry: number };
7
+ const recorded = new WeakMap<CapabilityObservation[], RecordedClaudeUsage>();
8
+
9
+ /** Cached aggregates outlive the bounded details, without retaining inputs or result bodies. */
10
+ export function recordedClaudeUsage(observations: CapabilityObservation[]): RecordedClaudeUsage | undefined {
11
+ return recorded.get(observations);
12
+ }
13
+
14
+ export function recordedClaudeUsageIsCurrent(observations: CapabilityObservation[]): boolean {
15
+ return Date.now() < (recorded.get(observations)?.nextExpiry ?? Infinity);
16
+ }
17
+
18
+ export function createClaudeUsageRecorder() {
19
+ const events = new Map<string, RemoteCapabilityUsageEvent>();
20
+ const cutoff = Date.now() - 30 * 86400_000;
21
+ return {
22
+ remember(observation: CapabilityObservation) {
23
+ if (!observation.mcpServer && !observation.skill) return;
24
+ const converted = toRemoteCapabilityUsageEvent(observation);
25
+ if (!converted || converted.createdAt < cutoff) return;
26
+ const event = { ...converted, agent: 'claude' as const };
27
+ events.delete(event.sourceId);
28
+ events.set(event.sourceId, event);
29
+ while (events.size > 20000) events.delete(events.keys().next().value!);
30
+ },
31
+ finish(observations: CapabilityObservation[]) {
32
+ const summaries = createExecutionSummaries(), totals = createCapabilityTotals();
33
+ const now = Date.now();
34
+ let nextExpiry = Infinity;
35
+ for (const event of events.values()) {
36
+ summaries.add(event, 'transcript');
37
+ totals.add(event);
38
+ for (const hours of CAPABILITY_TOTAL_WINDOWS) {
39
+ const expires = event.createdAt + hours * 3600_000;
40
+ if (expires > now) nextExpiry = Math.min(nextExpiry, expires);
41
+ }
42
+ }
43
+ recorded.set(observations, { summaries: summaries.rows(), totals: totals.rows(), nextExpiry });
44
+ events.clear();
45
+ },
46
+ };
47
+ }
48
+
49
+ /** Child source IDs belong to their own transcript; the Project uses the parent execution. */
50
+ export function mergeRecordedClaudeUsage(parent: CapabilityObservation[], child: CapabilityObservation[]): void {
51
+ const summaries = createExecutionSummaries(), totals = createCapabilityTotals();
52
+ let nextExpiry = Infinity;
53
+ for (const observations of [parent, child]) {
54
+ const usage = recorded.get(observations);
55
+ for (const summary of usage?.summaries ?? []) summaries.merge(summary);
56
+ for (const total of usage?.totals ?? []) totals.merge(total);
57
+ nextExpiry = Math.min(nextExpiry, usage?.nextExpiry ?? Infinity);
58
+ }
59
+ recorded.set(parent, { summaries: summaries.rows(), totals: totals.rows(), nextExpiry });
60
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from "../../telemetry";
21
21
  import { safeParse, ts } from "../../support/common";
22
22
  import { codexHeadRequest } from "../../support/codex-head";
23
+ import { completedCodexMcp, recordedCodexToolName } from '../../native-capability-history';
23
24
  import {
24
25
  CODEX_ACTIVITY_HEAD_BYTES,
25
26
  CODEX_ACTIVITY_TAIL_BYTES,
@@ -93,9 +94,10 @@ export function codexCapabilityUsage(
93
94
  const pending = new Map<string, PendingCapabilityTool>();
94
95
  const nestedByCall = new Map<string, Array<{ toolName: string; server: string }>>();
95
96
  const out: CapabilityObservation[] = [];
97
+ const host = { observed: false };
96
98
 
97
99
  lines.forEach((line, index) => ingestCodexCapabilityRow({
98
- line, index, session, pending, nestedByCall, out,
100
+ line, index, session, pending, nestedByCall, out, host,
99
101
  }));
100
102
 
101
103
  for (const [callId, item] of pending) {
@@ -103,7 +105,7 @@ export function codexCapabilityUsage(
103
105
  const nested = nestedByCall.get(callId) ?? [];
104
106
  if (observation && nested.length === 0) rememberCapabilityObservation(out, observation);
105
107
  const perCapability = Math.max(1, Math.ceil(estimateTokens(item.input) / Math.max(1, nested.length)));
106
- for (const [nestedIndex, tool] of nested.entries()) {
108
+ for (const [nestedIndex, tool] of (host.observed ? [] : nested).entries()) {
107
109
  rememberCapabilityObservation(out, {
108
110
  sourceId: `${item.sourceId}:nested:${nestedIndex}`,
109
111
  sessionId: item.sessionId,
@@ -115,7 +117,7 @@ export function codexCapabilityUsage(
115
117
  });
116
118
  }
117
119
  }
118
- return out;
120
+ return host.observed ? out.filter(item => !/:nested:\d+$/.test(item.sourceId)) : out;
119
121
  }
120
122
 
121
123
  function ingestCodexCapabilityRow(input: {
@@ -125,10 +127,18 @@ function ingestCodexCapabilityRow(input: {
125
127
  pending: Map<string, PendingCapabilityTool>;
126
128
  nestedByCall: Map<string, Array<{ toolName: string; server: string }>>;
127
129
  out: CapabilityObservation[];
130
+ host: { observed: boolean };
128
131
  }): void {
129
132
  const { line, index, session, pending, nestedByCall, out } = input;
130
133
  const row = safeParse(line);
131
- if (!row || row.type !== "response_item") return;
134
+ if (!row) return;
135
+ if (row.type === 'event_msg' && row.payload?.type === 'item_completed') {
136
+ input.host.observed = true;
137
+ const actual = completedCodexMcp(row, session);
138
+ if (actual) rememberCapabilityObservation(out, actual);
139
+ return;
140
+ }
141
+ if (row.type !== "response_item") return;
132
142
  const payload = row.payload ?? {};
133
143
  const rowAt = ts(row.timestamp);
134
144
  if (["function_call", "custom_tool_call", "local_shell_call"].includes(payload.type)) {
@@ -136,7 +146,7 @@ function ingestCodexCapabilityRow(input: {
136
146
  const item: PendingCapabilityTool = {
137
147
  sourceId: `${session.sessionId}:${callId}`,
138
148
  sessionId: session.sessionId,
139
- toolName: String(payload.name ?? payload.type),
149
+ toolName: recordedCodexToolName(payload),
140
150
  input: codexToolInput(payload),
141
151
  createdAt: rowAt || session.lastActivityAt,
142
152
  cwd: session.cwd ?? undefined,
@@ -71,6 +71,16 @@ function textBlocks(content: unknown): string[] {
71
71
  });
72
72
  }
73
73
 
74
+ function visibleRole(row: Record<string, unknown>, message: Record<string, unknown>): string {
75
+ if (row.synthetic_reason != null || message.synthetic_reason != null) return "";
76
+ const role = message.role ?? row.role;
77
+ if (role != null) return String(role);
78
+ const type = message.type ?? row.type;
79
+ const index = message.prompt_index ?? row.prompt_index;
80
+ if (type === "user" && (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0)) return "";
81
+ return typeof type === "string" ? type : "";
82
+ }
83
+
74
84
  function parseLog(path: string, sessionId: string, fallbackTime: number, fullText = false): ParsedLog {
75
85
  let lines: string[];
76
86
  try { lines = readFileSync(path, "utf8").split("\n"); } catch {
@@ -87,7 +97,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
87
97
  const row = safeParse(line);
88
98
  if (!row) return;
89
99
  const message = row.message && typeof row.message === "object" ? row.message : row;
90
- const role = String(message.role ?? row.role ?? "");
100
+ const role = visibleRole(row, message);
91
101
  if (role === "reasoning" || role === "system") return;
92
102
  const messageAt = ts(row.timestamp ?? message.timestamp);
93
103
  const createdAt = messageAt || fallbackTime + index;
@@ -154,9 +164,9 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
154
164
  try { stat = statSync(join(directory, "chat_history.jsonl")); } catch {
155
165
  try { stat = statSync(path); } catch { continue; }
156
166
  }
157
- const parsed = parseLog(join(directory, "chat_history.jsonl"), sessionId, stat.mtimeMs);
158
167
  const updatedAt = ts(summary.updated_at) || stat.mtimeMs;
159
168
  const startedAt = ts(summary.created_at) || stat.birthtimeMs || updatedAt;
169
+ const parsed = parseLog(join(directory, "chat_history.jsonl"), sessionId, startedAt);
160
170
  const title = [summary.generated_title, summary.session_summary, parsed.firstUser]
161
171
  .find((value): value is string => typeof value === "string" && value.trim().length > 0);
162
172
  const cwd = typeof summary.info?.cwd === "string" ? summary.info.cwd : undefined;
@@ -0,0 +1,56 @@
1
+ import type { CapabilityExecutionSummary, CapabilityUsageStatus, RemoteCapabilityUsageEvent } from '../../../../../packages/protocol/schema';
2
+ import { MAX_CAPABILITY_USAGE_PAYLOAD_BYTES } from './wire-budget';
3
+
4
+ /** Preserve execution attribution before recent call details are trimmed. */
5
+ export function createExecutionSummaries() {
6
+ const rows = new Map<string, CapabilityExecutionSummary>();
7
+ const seen = new Set<string>();
8
+ return {
9
+ add(event: RemoteCapabilityUsageEvent, source: 'native-history' | 'transcript') {
10
+ if (event.kind === 'cli' || !event.sessionId || !event.agent) return;
11
+ const identity = JSON.stringify([event.agent, event.sessionId, event.sourceId]);
12
+ if (seen.has(identity)) return;
13
+ seen.add(identity);
14
+ const key = JSON.stringify([event.agent, event.sessionId, event.kind, event.name]);
15
+ const row = rows.get(key) ?? { sessionId: event.sessionId, agent: event.agent,
16
+ kind: event.kind, name: event.name, count: 0, failures: 0, cancelled: 0,
17
+ lastUsedAt: 0, source };
18
+ row.count++;
19
+ row.failures += event.outcome === 'error' ? 1 : 0;
20
+ row.cancelled += event.outcome === 'cancelled' ? 1 : 0;
21
+ row.lastUsedAt = Math.max(row.lastUsedAt, event.createdAt);
22
+ if (event.durationMs != null) {
23
+ row.durationMs = (row.durationMs ?? 0) + event.durationMs;
24
+ row.durationCount = (row.durationCount ?? 0) + 1;
25
+ }
26
+ rows.set(key, row);
27
+ },
28
+ merge(summary: CapabilityExecutionSummary) {
29
+ const key = JSON.stringify([summary.agent, summary.sessionId, summary.kind, summary.name]);
30
+ const previous = rows.get(key);
31
+ if (!previous) { rows.set(key, { ...summary }); return; }
32
+ previous.count += summary.count;
33
+ previous.failures += summary.failures;
34
+ previous.cancelled += summary.cancelled;
35
+ previous.lastUsedAt = Math.max(previous.lastUsedAt, summary.lastUsedAt);
36
+ if (summary.durationCount) {
37
+ previous.durationCount = (previous.durationCount ?? 0) + summary.durationCount;
38
+ previous.durationMs = (previous.durationMs ?? 0) + (summary.durationMs ?? 0);
39
+ }
40
+ },
41
+ rows: () => [...rows.values()].sort((a, b) => b.lastUsedAt - a.lastUsedAt).slice(0, 128),
42
+ };
43
+ }
44
+
45
+ export function budgetExecutionSummaries(rows: CapabilityExecutionSummary[],
46
+ envelope: Omit<CapabilityUsageStatus, 'events' | 'executionSummaries'>): CapabilityExecutionSummary[] {
47
+ const budget = Math.max(0, MAX_CAPABILITY_USAGE_PAYLOAD_BYTES - 8192
48
+ - Buffer.byteLength(JSON.stringify({ ...envelope, events: [], executionSummaries: [] })));
49
+ let bytes = 0;
50
+ return rows.filter(row => {
51
+ const size = Buffer.byteLength(JSON.stringify(row)) + 1;
52
+ if (bytes + size > budget) return false;
53
+ bytes += size;
54
+ return true;
55
+ }).slice(0, 128);
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { desktopTaskActivity } from "./task-activity";
3
3
  import { desktopTaskImage } from "./image";
4
- import { scanCapabilityUsage, scanSessions, scanSessionHistory } from "../../../bridge/src/sessions";
4
+ import { scanRecordedCapabilityUsage, scanSessions, scanSessionHistory } from "../../../bridge/src/sessions";
5
5
  import { desktopSelectedMesh } from "./projection/selected-mesh";
6
6
  import { desktopUsageSessions } from "./projection/session-usage";
7
7
  import { desktopLiveCatalog } from "./live-catalog";
@@ -23,10 +23,10 @@ function cachedActivity(query: unknown, storePath?: string) {
23
23
  return result;
24
24
  }
25
25
 
26
- function cachedUsage(): unknown {
26
+ async function cachedUsage(): Promise<unknown> {
27
27
  if (usageCache && Date.now() - usageCache.at < 120_000) return usageCache.result;
28
28
  const sessions = [...scanSessions().sessions, ...scanSessionHistory()];
29
- const result = { ...scanCapabilityUsage(sessions), operation: "desktop.capability_usage",
29
+ const result = { ...await scanRecordedCapabilityUsage(sessions), operation: "desktop.capability_usage",
30
30
  sessions: desktopUsageSessions(sessions) };
31
31
  usageCache = { at: Date.now(), result };
32
32
  return result;
@@ -39,7 +39,7 @@ for await (const line of createInterface({ input: process.stdin, crlfDelay: Infi
39
39
  const request = JSON.parse(line) as { id: string; query: unknown; storePath?: string };
40
40
  const result = request.query && typeof request.query === "object"
41
41
  && (request.query as { operation?: string }).operation === "desktop.capability_usage"
42
- ? cachedUsage()
42
+ ? await cachedUsage()
43
43
  : request.query && typeof request.query === "object"
44
44
  && (request.query as { operation?: string }).operation === "desktop.task_capabilities"
45
45
  ? desktopTaskCapabilities(request.query, request.storePath) ?? null
@@ -0,0 +1,15 @@
1
+ # Local HTTP service
2
+
3
+ `http-server.ts` is the public entry point for the loopback MCP, OAuth and native
4
+ desktop service. Listener addresses must be loopback addresses.
5
+
6
+ `GET /healthz` and `GET /desktop/status` return bounded observations without
7
+ starting provider CLIs or changing provider configuration. Desktop discovery
8
+ uses the latest available hook observations; an unconfirmed status stays
9
+ unconfirmed. Opening the Mac client does not wait for a Codex subprocess.
10
+
11
+ Authenticated native operations use the desktop bridge. Pairing material and
12
+ credentials are excluded from public status responses.
13
+ ## License
14
+
15
+ Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
@@ -1,4 +1,3 @@
1
- import { refreshCodexHooks } from "../../../bridge/src/codex-hook-trust";
2
1
  /**
3
2
  * GrantTap MCP over Streamable HTTP + loopback OAuth with website consent.
4
3
  *
@@ -99,8 +98,7 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
99
98
  mcp: mcpUrl.href,
100
99
  });
101
100
  });
102
- app.get("/desktop/status", async (_req, res) => {
103
- await refreshCodexHooks();
101
+ app.get("/desktop/status", (_req, res) => {
104
102
  res.set("Cache-Control", "no-store");
105
103
  res.json(desktopStatusSnapshot(desktopBridge.socketPath));
106
104
  });
@@ -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.86
13
+ npm install -g granttap-mcp@0.8.87
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.86\";\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 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.87\";\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 22.13.0+ 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.86";
10
+ const PACKAGE = "granttap-mcp@0.8.87";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.86",
3
+ "version": "0.8.87",
4
4
  "description": "Commercial computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,12 @@ export type RelayClientOptions = {
46
46
  };
47
47
 
48
48
  export type SendOptions = {
49
+ /** Negotiated opaque latest-state replay, independent of delivery ACKs. */
50
+ retainedState?: boolean;
51
+ stateKey?: string;
52
+ stateGroup?: string;
53
+ stateRevision?: number;
54
+ statePriority?: 0 | 1 | 2 | 3;
49
55
  /** Set by negotiated RelayClient transport; absent for older relays. */
50
56
  recipientRouting?: boolean;
51
57
  /** Reply only to the authenticated requester; absent means all authorized peers. */
@@ -1,8 +1,9 @@
1
+ import { retainedPayloadOptions } from "./retained-state";
1
2
  /** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
2
3
  import WebSocket from "ws";
3
4
  import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, RelayConnectionNotifications, relayUpgradeHeaders } from "./socket-lifecycle";
4
5
  import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
5
- import { openRelayMessage, parseRelayControl } from "./relay-inbound";
6
+ import { openRelayMessage, parseRelayControl, waitForRelayPayload } from "./relay-inbound";
6
7
  import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
7
8
  import { Payload, type Role } from "../protocol/schema";
8
9
  import { peerPublicKeys } from "./crypto";
@@ -17,6 +18,7 @@ type Listener = (p: Payload, peerPublicKey: string) => boolean | void | Promise<
17
18
  export class RelayClient {
18
19
  private ws?: WebSocket;
19
20
  private recipientRouting = false;
21
+ private retainedState = false;
20
22
  private frames?: OutboundFrameQueue;
21
23
  private listeners = new Set<Listener>();
22
24
  private readonly connections = new RelayConnectionNotifications();
@@ -61,6 +63,7 @@ export class RelayClient {
61
63
 
62
64
  this.intentionalClose = false;
63
65
  this.recipientRouting = false;
66
+ this.retainedState = false;
64
67
  const pending = Promise.resolve().then(() => this.opts.upgradeHeaders?.() ?? {})
65
68
  .then(headers => new Promise<void>((resolve, reject) => {
66
69
  // The room rides in the URL: the Cloudflare relay must pick a Durable
@@ -127,9 +130,10 @@ export class RelayClient {
127
130
  }
128
131
 
129
132
  private async onRaw(raw: string): Promise<void> {
130
- const control = parseRelayControl(raw) as { type?: string; recipientRouting?: boolean } | null;
133
+ const control = parseRelayControl(raw) as { type?: string; recipientRouting?: boolean; retainedState?: boolean; retainedStateGroups?: boolean } | null;
131
134
  if (control?.type === "relay.capabilities" && control.recipientRouting === true) {
132
135
  this.recipientRouting = true;
136
+ this.retainedState = control.retainedState === true && control.retainedStateGroups === true;
133
137
  return;
134
138
  }
135
139
  if (control?.type === "relay.task-job") {
@@ -181,6 +185,7 @@ export class RelayClient {
181
185
  const ws = this.ws;
182
186
  if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
183
187
  if (!this.frames) throw new Error("relay not connected");
188
+ options = { ...retainedPayloadOptions(payload), ...options, retainedState: this.retainedState };
184
189
  if (options.peerPublicKey) {
185
190
  await this.sendToPeer(payload, options.peerPublicKey, options);
186
191
  return;
@@ -208,7 +213,7 @@ export class RelayClient {
208
213
  if (!peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys).includes(peerPublicKey)
209
214
  || !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
210
215
  if (!this.frames) throw new Error("relay not connected");
211
- await this.frames.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), { ...options, recipientRouting: this.recipientRouting }), true);
216
+ await this.frames.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), { ...retainedPayloadOptions(payload), ...options, retainedState: this.retainedState, recipientRouting: this.recipientRouting }), true);
212
217
  }
213
218
 
214
219
  setSessionKey(sessionId: string, key: string): void {
@@ -226,7 +231,7 @@ export class RelayClient {
226
231
  to: Role | "all" = this.otherRole(),
227
232
  options: SendOptions = {},
228
233
  ): Promise<void> {
229
- await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, options);
234
+ await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, { ...retainedPayloadOptions(payload), ...options });
230
235
  }
231
236
 
232
237
  /** Register before connect so only durable receivers advertise task-job support. */
@@ -253,21 +258,7 @@ export class RelayClient {
253
258
 
254
259
  /** Resolve with the first payload matching `pred`, or reject on timeout. */
255
260
  waitFor<T extends Payload>(pred: (p: Payload) => p is T, timeoutMs: number): Promise<T> {
256
- return new Promise((resolve, reject) => {
257
- const timer = setTimeout(() => {
258
- off();
259
- reject(new Error("waitFor timeout"));
260
- }, timeoutMs);
261
- const off = this.onMessage((p) => {
262
- if (pred(p)) {
263
- clearTimeout(timer);
264
- off();
265
- resolve(p);
266
- return true;
267
- }
268
- return false;
269
- });
270
- });
261
+ return waitForRelayPayload(listener => this.onMessage(listener), pred, timeoutMs);
271
262
  }
272
263
 
273
264
  close(): void {
@@ -281,6 +272,10 @@ export class RelayClient {
281
272
  this.ws = undefined;
282
273
  }
283
274
 
275
+ get supportsRetainedState(): boolean {
276
+ return this.retainedState;
277
+ }
278
+
284
279
  get isConnected(): boolean {
285
280
  return this.ws?.readyState === WebSocket.OPEN;
286
281
  }
@@ -20,18 +20,22 @@ export function sealEnvelope(
20
20
  options: SendOptions = {},
21
21
  ): string {
22
22
  const { nonce, box } = seal(payload, cfg.peerPublicKey, cfg.mySecretKey);
23
+ const retained = cfg.role === "machine" && to === "phone" && options.retainedState && options.stateKey;
23
24
  const envelope: Envelope = {
25
+ ...(retained ? { stateKey: options.stateKey, stateRevision: options.stateRevision,
26
+ statePriority: options.statePriority, stateGroup: options.stateGroup } : {}),
24
27
  v: PROTOCOL_VERSION,
25
28
  room: cfg.room,
26
29
  from: cfg.role,
27
30
  to,
28
31
  senderId: cfg.senderId,
29
- recipientKeyId: options.recipientRouting ? publicKeyIdentifier(cfg.peerPublicKey) : undefined,
30
- deliveryId: options.reliable === false
32
+ recipientKeyId: options.recipientRouting || retained ? publicKeyIdentifier(cfg.peerPublicKey) : undefined,
33
+ deliveryId: retained || options.reliable === false
31
34
  ? undefined
32
35
  : options.deliveryId ?? randomUUID(),
33
36
  wake: options.wake,
34
- expiresAt: options.ttlMs == null ? undefined : Date.now() + options.ttlMs,
37
+ expiresAt: retained ? Date.now() + 7 * 86400000 - 300000
38
+ : options.ttlMs == null ? undefined : Date.now() + options.ttlMs,
35
39
  nonce,
36
40
  box,
37
41
  };
@@ -54,3 +54,5 @@ function unwrapTaskPayload(payload: Payload, role: string, sessionKeys: Map<stri
54
54
  || sessionId !== payload.sessionId) return undefined;
55
55
  return inner.data;
56
56
  }
57
+
58
+ export { waitForRelayPayload } from "./wait-for";
@@ -0,0 +1,19 @@
1
+ import type { Payload } from "../../protocol/schema";
2
+
3
+ /** One bounded payload wait, cancelled by its own match or deadline. */
4
+ export function waitForRelayPayload<T extends Payload>(
5
+ subscribe: (listener: (payload: Payload) => boolean | void) => () => void,
6
+ predicate: (payload: Payload) => payload is T,
7
+ timeoutMs: number,
8
+ ): Promise<T> {
9
+ return new Promise((resolve, reject) => {
10
+ const timer = setTimeout(() => { off(); reject(new Error("waitFor timeout")); }, timeoutMs);
11
+ const off = subscribe(payload => {
12
+ if (!predicate(payload)) return;
13
+ clearTimeout(timer);
14
+ off();
15
+ resolve(payload);
16
+ return true;
17
+ });
18
+ });
19
+ }
@@ -0,0 +1,23 @@
1
+ # Relay state publication
2
+
3
+ Public entry: `retainedPayloadOptions`. The transport negotiates latest-state
4
+ retention before adding its opaque metadata. It retains catalogs, task-key
5
+ grants, Mesh snapshots and transcript heads for each approved recipient. Commands,
6
+ approval decisions and historical transcript pages never replace a current head.
7
+ The relay keeps only ciphertext, bounded by its room capacity and expiry.
8
+
9
+ Key grants replay first, followed by catalogs, Mesh and heads. Generation times
10
+ remain unchanged, so saved work cannot pretend to be a new provider response.
11
+ A catalog with historical tasks occupies a separate slot from the active-only
12
+ catalog. Current heads are published in fair batches of at most 32 even when no
13
+ phone subscribes, independently of catalog and heartbeat publication.
14
+
15
+ The phone's queue is restored through the separate durable task-job API. Relay
16
+ state caching does not execute commands or change provider policy. Older relays
17
+ continue using live updates and the existing acknowledged mailbox.
18
+
19
+ Tests live in `tests/` and run with the repository's test and release commands.
20
+
21
+ GrantTap Commercial Source License; see the repository LICENSE.
22
+
23
+ Opaque group identifiers keep each scoped key with its encrypted snapshots during eviction. Grants for historical pages or other noncached replies are not retained as read state. Oversized read payloads stay live rather than reserving an orphan key slot.
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Payload } from '../../protocol/schema';
3
+ import type { SendOptions } from '../relay-client-types';
4
+
5
+ /** Only replaceable read state is retained. Decisions and commands are never replayed as state. */
6
+ export function retainedPayloadOptions(payload: Payload): SendOptions {
7
+ let scope: string | undefined;
8
+ let revision: number | undefined;
9
+ let session: string | undefined;
10
+ let priority: 0 | 1 | 2 | 3 = 1;
11
+ switch (payload.type) {
12
+ case 'sessions.status': scope = payload.history ? 'catalog:history' : 'catalog'; revision = payload.generatedAt; break;
13
+ case 'session.key.grant': session = payload.sessionId; scope = `key:${payload.sessionId}`; revision = payload.createdAt; priority = 0; break;
14
+ case 'session.activity':
15
+ session = payload.sessionId;
16
+ if (!payload.history?.requestedCursor) scope = `activity:${payload.sessionId}:${payload.threadId ?? ''}`;
17
+ revision = payload.generatedAt; priority = 3; break;
18
+ case 'mesh.snapshot': session = payload.sessionId; scope = `mesh:${payload.projectId}`; revision = payload.generatedAt; priority = 2; break;
19
+ case 'capability.usage.status': session = 'capability-usage'; scope = 'capability-usage';
20
+ revision = payload.generatedAt; priority = 2; break;
21
+ }
22
+ if (!scope || !Number.isSafeInteger(revision) || revision! < 0
23
+ || Buffer.byteLength(JSON.stringify(payload)) > 900000) return {};
24
+ return { stateGroup: session ? createHash('sha256').update(`session:${session}`).digest('hex') : undefined, stateKey: createHash('sha256').update(scope).digest('hex'), stateRevision: revision, statePriority: priority };
25
+ }
@@ -143,10 +143,27 @@ export const CapabilityUsageTotal = z.object({
143
143
  }).strict();
144
144
  export type CapabilityUsageTotal = z.infer<typeof CapabilityUsageTotal>;
145
145
 
146
+ /** Recorded usage before the recent-call transport budget is applied. */
147
+ export const CapabilityExecutionSummary = z.object({
148
+ sessionId: z.string().trim().min(1).max(256),
149
+ agent: z.string().trim().min(1).max(80),
150
+ kind: z.enum(["mcp", "skill"]),
151
+ name: CapabilityName,
152
+ count: z.number().int().positive(),
153
+ failures: z.number().int().nonnegative(),
154
+ cancelled: z.number().int().nonnegative(),
155
+ lastUsedAt: z.number().nonnegative(),
156
+ durationMs: z.number().int().nonnegative().optional(),
157
+ durationCount: z.number().int().nonnegative().optional(),
158
+ source: z.enum(["native-history", "transcript"]),
159
+ }).strict();
160
+ export type CapabilityExecutionSummary = z.infer<typeof CapabilityExecutionSummary>;
161
+
146
162
  export const CapabilityUsageStatus = z.object({
147
163
  type: z.literal("capability.usage.status"),
148
164
  events: z.array(CapabilityUsageEvent).max(200),
149
165
  totals: z.array(CapabilityUsageTotal).max(160).optional(),
166
+ executionSummaries: z.array(CapabilityExecutionSummary).max(128).optional(),
150
167
  generatedAt: z.number(),
151
168
  });
152
169
  export type CapabilityUsageStatus = z.infer<typeof CapabilityUsageStatus>;
@@ -202,6 +202,10 @@ export type Payload = z.infer<typeof Payload>;
202
202
 
203
203
  /** The routed unit. `nonce`+`box` are the sealed Payload; relay cannot open it. */
204
204
  export const Envelope = z.object({
205
+ stateGroup: z.string().regex(/^[a-f0-9]{64}$/).optional(),
206
+ stateKey: z.string().regex(/^[a-f0-9]{64}$/).optional(),
207
+ stateRevision: z.number().int().nonnegative().optional(),
208
+ statePriority: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
205
209
  recipientKeyId: z.string().regex(/^[a-f0-9]{64}$/).optional(),
206
210
  v: z.literal(PROTOCOL_VERSION),
207
211
  room: z.string(),