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.
- package/README.md +4 -0
- package/apps/bridge/src/host/session-keys.ts +5 -1
- package/apps/bridge/src/monitor/capability-publication/index.ts +9 -4
- package/apps/bridge/src/monitor/support/runtime.ts +8 -5
- package/apps/bridge/src/session-catalog/README.md +5 -4
- package/apps/bridge/src/session-catalog/publish-heads.ts +10 -4
- package/apps/bridge/src/sessions/README.md +8 -0
- package/apps/bridge/src/sessions/index.ts +26 -5
- package/apps/bridge/src/sessions/native-capability-history/README.md +20 -0
- package/apps/bridge/src/sessions/native-capability-history/completed-item.ts +31 -0
- package/apps/bridge/src/sessions/native-capability-history/index.ts +67 -0
- package/apps/bridge/src/sessions/native-capability-history/reader.js +97 -0
- package/apps/bridge/src/sessions/scan/claude/activity.ts +10 -3
- package/apps/bridge/src/sessions/scan/claude/index.ts +1 -0
- package/apps/bridge/src/sessions/scan/claude/parse.ts +6 -2
- package/apps/bridge/src/sessions/scan/claude/recorded-usage/README.md +17 -0
- package/apps/bridge/src/sessions/scan/claude/recorded-usage/index.ts +60 -0
- package/apps/bridge/src/sessions/scan/codex/activity.ts +15 -5
- package/apps/bridge/src/sessions/scan/grok.ts +12 -2
- package/apps/bridge/src/sessions/support/capability-totals.ts +0 -0
- package/apps/bridge/src/sessions/telemetry/execution-summaries.ts +56 -0
- package/apps/mcp/src/desktop/task-activity-worker.ts +4 -4
- package/apps/mcp/src/http/README.md +15 -0
- package/apps/mcp/src/http/http-server.ts +1 -3
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client-types.ts +6 -0
- package/packages/core/relay-client.ts +14 -19
- package/packages/core/relay-envelope.ts +7 -3
- package/packages/core/relay-inbound/index.ts +2 -0
- package/packages/core/relay-inbound/wait-for.ts +19 -0
- package/packages/core/retained-state/README.md +23 -0
- package/packages/core/retained-state/index.ts +25 -0
- package/packages/protocol/messages/capabilities.ts +17 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
131
|
+
if (observation) remember(observation);
|
|
126
132
|
}
|
|
133
|
+
recorder.finish(out);
|
|
127
134
|
return out;
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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;
|
|
Binary file
|
|
@@ -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 {
|
|
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 = { ...
|
|
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",
|
|
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
|
});
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.87
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.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.
|
|
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
|
@@ -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
|
|
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:
|
|
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
|
};
|
|
@@ -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(),
|