aegis-bridge 0.1.0
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/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-registry.ts — Tool usage tracking and registry for CC tool introspection.
|
|
3
|
+
*
|
|
4
|
+
* Parses tool_use messages from JSONL transcripts to build per-session
|
|
5
|
+
* and global tool usage metrics. Exposes API endpoints for observability.
|
|
6
|
+
*
|
|
7
|
+
* Issue #704: Tool registry and schema validation for CC tool introspection.
|
|
8
|
+
*/
|
|
9
|
+
import type { ParsedEntry } from './transcript.js';
|
|
10
|
+
/** Known CC tool definitions with metadata. */
|
|
11
|
+
export interface ToolDefinition {
|
|
12
|
+
name: string;
|
|
13
|
+
category: string;
|
|
14
|
+
description: string;
|
|
15
|
+
permissionLevel: string;
|
|
16
|
+
}
|
|
17
|
+
/** Per-tool usage stats within a session. */
|
|
18
|
+
export interface ToolUsageRecord {
|
|
19
|
+
name: string;
|
|
20
|
+
count: number;
|
|
21
|
+
lastUsedAt: number;
|
|
22
|
+
firstUsedAt: number;
|
|
23
|
+
errors: number;
|
|
24
|
+
}
|
|
25
|
+
/** Tool registry: known tools + per-session usage tracking. */
|
|
26
|
+
export declare class ToolRegistry {
|
|
27
|
+
private sessionUsage;
|
|
28
|
+
/** Built-in CC tool definitions (from CC src/tools/). */
|
|
29
|
+
private readonly tools;
|
|
30
|
+
/** Process parsed entries and extract tool usage. */
|
|
31
|
+
processEntries(sessionId: string, entries: ParsedEntry[]): void;
|
|
32
|
+
/** Get tool usage for a session, sorted by count descending. */
|
|
33
|
+
getSessionTools(sessionId: string): ToolUsageRecord[];
|
|
34
|
+
/** Get all known CC tool definitions. */
|
|
35
|
+
getToolDefinitions(): ToolDefinition[];
|
|
36
|
+
/** Get a tool definition by name. */
|
|
37
|
+
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
38
|
+
/** Clean up session data. */
|
|
39
|
+
cleanupSession(sessionId: string): void;
|
|
40
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-registry.ts — Tool usage tracking and registry for CC tool introspection.
|
|
3
|
+
*
|
|
4
|
+
* Parses tool_use messages from JSONL transcripts to build per-session
|
|
5
|
+
* and global tool usage metrics. Exposes API endpoints for observability.
|
|
6
|
+
*
|
|
7
|
+
* Issue #704: Tool registry and schema validation for CC tool introspection.
|
|
8
|
+
*/
|
|
9
|
+
/** Tool registry: known tools + per-session usage tracking. */
|
|
10
|
+
export class ToolRegistry {
|
|
11
|
+
sessionUsage = new Map();
|
|
12
|
+
/** Built-in CC tool definitions (from CC src/tools/). */
|
|
13
|
+
tools = [
|
|
14
|
+
{ name: 'Read', category: 'read', description: 'Read file contents', permissionLevel: 'read' },
|
|
15
|
+
{ name: 'Write', category: 'write', description: 'Write file contents', permissionLevel: 'write' },
|
|
16
|
+
{ name: 'Edit', category: 'edit', description: 'Edit file with search/replace', permissionLevel: 'edit' },
|
|
17
|
+
{ name: 'MultiEdit', category: 'edit', description: 'Multiple edits in one operation', permissionLevel: 'edit' },
|
|
18
|
+
{ name: 'Bash', category: 'bash', description: 'Execute shell commands', permissionLevel: 'bash' },
|
|
19
|
+
{ name: 'Glob', category: 'search', description: 'Find files matching pattern', permissionLevel: 'read' },
|
|
20
|
+
{ name: 'Grep', category: 'search', description: 'Search file contents', permissionLevel: 'read' },
|
|
21
|
+
{ name: 'ListFiles', category: 'search', description: 'List directory contents', permissionLevel: 'read' },
|
|
22
|
+
{ name: 'TodoWrite', category: 'edit', description: 'Update todo list', permissionLevel: 'edit' },
|
|
23
|
+
{ name: 'TodoRead', category: 'read', description: 'Read todo list', permissionLevel: 'read' },
|
|
24
|
+
{ name: 'WebFetch', category: 'read', description: 'Fetch web page content', permissionLevel: 'read' },
|
|
25
|
+
{ name: 'NotebookRead', category: 'read', description: 'Read notebook cells', permissionLevel: 'read' },
|
|
26
|
+
{ name: 'NotebookEdit', category: 'edit', description: 'Edit notebook cells', permissionLevel: 'edit' },
|
|
27
|
+
{ name: 'AskUserQuestion', category: 'agent', description: 'Ask user for clarification', permissionLevel: 'read' },
|
|
28
|
+
{ name: 'AgentTool', category: 'agent', description: 'Spawn sub-agent for parallel execution', permissionLevel: 'agent' },
|
|
29
|
+
{ name: 'MCPTool', category: 'mcp', description: 'MCP server tool invocation', permissionLevel: 'mcp' },
|
|
30
|
+
];
|
|
31
|
+
/** Process parsed entries and extract tool usage. */
|
|
32
|
+
processEntries(sessionId, entries) {
|
|
33
|
+
let usage = this.sessionUsage.get(sessionId);
|
|
34
|
+
if (!usage) {
|
|
35
|
+
usage = new Map();
|
|
36
|
+
this.sessionUsage.set(sessionId, usage);
|
|
37
|
+
}
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.contentType === 'tool_use' && entry.toolName) {
|
|
41
|
+
const existing = usage.get(entry.toolName);
|
|
42
|
+
if (existing) {
|
|
43
|
+
existing.count++;
|
|
44
|
+
existing.lastUsedAt = now;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
usage.set(entry.toolName, {
|
|
48
|
+
name: entry.toolName,
|
|
49
|
+
count: 1,
|
|
50
|
+
lastUsedAt: now,
|
|
51
|
+
firstUsedAt: now,
|
|
52
|
+
errors: 0,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (entry.contentType === 'tool_error' && entry.toolName) {
|
|
57
|
+
const existing = usage.get(entry.toolName);
|
|
58
|
+
if (existing) {
|
|
59
|
+
existing.errors++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Get tool usage for a session, sorted by count descending. */
|
|
65
|
+
getSessionTools(sessionId) {
|
|
66
|
+
const usage = this.sessionUsage.get(sessionId);
|
|
67
|
+
if (!usage)
|
|
68
|
+
return [];
|
|
69
|
+
return [...usage.values()].sort((a, b) => b.count - a.count);
|
|
70
|
+
}
|
|
71
|
+
/** Get all known CC tool definitions. */
|
|
72
|
+
getToolDefinitions() {
|
|
73
|
+
return [...this.tools];
|
|
74
|
+
}
|
|
75
|
+
/** Get a tool definition by name. */
|
|
76
|
+
getToolDefinition(name) {
|
|
77
|
+
return this.tools.find(t => t.name === name);
|
|
78
|
+
}
|
|
79
|
+
/** Clean up session data. */
|
|
80
|
+
cleanupSession(sessionId) {
|
|
81
|
+
this.sessionUsage.delete(sessionId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript.ts — JSONL transcript parser for Claude Code sessions.
|
|
3
|
+
*
|
|
4
|
+
* Port of CCBot's transcript_parser.py.
|
|
5
|
+
* Reads CC session JSONL files and extracts structured messages.
|
|
6
|
+
*/
|
|
7
|
+
export interface ParsedEntry {
|
|
8
|
+
role: 'user' | 'assistant' | 'system';
|
|
9
|
+
contentType: 'text' | 'thinking' | 'tool_use' | 'tool_result' | 'tool_error' | 'permission_request' | 'progress';
|
|
10
|
+
text: string;
|
|
11
|
+
toolName?: string;
|
|
12
|
+
toolUseId?: string;
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
}
|
|
15
|
+
interface ContentBlock {
|
|
16
|
+
type: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
thinking?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
tool_use_id?: string;
|
|
22
|
+
input?: Record<string, unknown>;
|
|
23
|
+
content?: unknown;
|
|
24
|
+
is_error?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Issue #488: Cumulative token usage extracted from a batch of JSONL entries. */
|
|
27
|
+
export interface TokenUsageDelta {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cacheCreationTokens: number;
|
|
31
|
+
cacheReadTokens: number;
|
|
32
|
+
}
|
|
33
|
+
export interface JsonlEntry {
|
|
34
|
+
type: string;
|
|
35
|
+
message?: {
|
|
36
|
+
role: string;
|
|
37
|
+
content: string | ContentBlock[];
|
|
38
|
+
stop_reason?: string;
|
|
39
|
+
/** Token usage reported by the model (present on assistant messages). */
|
|
40
|
+
usage?: {
|
|
41
|
+
input_tokens?: number;
|
|
42
|
+
output_tokens?: number;
|
|
43
|
+
cache_creation_input_tokens?: number;
|
|
44
|
+
cache_read_input_tokens?: number;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
timestamp?: string;
|
|
48
|
+
tool_use_id?: string;
|
|
49
|
+
data?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
/** Parse entries from JSONL data. */
|
|
52
|
+
export declare function parseEntries(entries: JsonlEntry[]): ParsedEntry[];
|
|
53
|
+
/** Issue #488: Sum token usage across a batch of raw JSONL entries. */
|
|
54
|
+
export declare function extractTokenDelta(raw: JsonlEntry[]): TokenUsageDelta;
|
|
55
|
+
/** Read JSONL file from byte offset, return new entries + new offset. */
|
|
56
|
+
export declare function readNewEntries(filePath: string, fromOffset: number): Promise<{
|
|
57
|
+
entries: ParsedEntry[];
|
|
58
|
+
newOffset: number;
|
|
59
|
+
raw: JsonlEntry[];
|
|
60
|
+
}>;
|
|
61
|
+
/** Find the JSONL file for a session ID. */
|
|
62
|
+
export declare function findSessionFile(sessionId: string, claudeProjectsDir?: string): Promise<string | null>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript.ts — JSONL transcript parser for Claude Code sessions.
|
|
3
|
+
*
|
|
4
|
+
* Port of CCBot's transcript_parser.py.
|
|
5
|
+
* Reads CC session JSONL files and extracts structured messages.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, open, access } from 'node:fs/promises';
|
|
8
|
+
import { createReadStream } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { readdir } from 'node:fs/promises';
|
|
12
|
+
import { sessionsIndexSchema } from './validation.js';
|
|
13
|
+
import { safeJsonParse, safeJsonParseSchema } from './safe-json.js';
|
|
14
|
+
/** Default Claude projects directory */
|
|
15
|
+
const DEFAULT_CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
16
|
+
/** Parse a single JSONL line. Returns null if not parseable.
|
|
17
|
+
* Issue #823: Logs at error level when a non-empty line is dropped. */
|
|
18
|
+
function parseLine(line) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed || trimmed[0] !== '{') {
|
|
21
|
+
// Lines that are blank or don't start with '{' are expected (separators, comments)
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const parsed = safeJsonParse(trimmed, 'JSONL line');
|
|
25
|
+
if (!parsed.ok) {
|
|
26
|
+
// Issue #823: Log malformed JSON lines so data loss is visible
|
|
27
|
+
console.error(`parseLine: dropping malformed JSONL line (${parsed.error}): ${trimmed.slice(0, 200)}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return parsed.data;
|
|
31
|
+
}
|
|
32
|
+
/** Summarize a tool_use block. */
|
|
33
|
+
function summarizeTool(name, input) {
|
|
34
|
+
switch (name) {
|
|
35
|
+
case 'Read':
|
|
36
|
+
case 'ReadNotebook':
|
|
37
|
+
return `📖 **Read** ${input.file_path || input.path || ''}`;
|
|
38
|
+
case 'Write':
|
|
39
|
+
case 'MultiWrite':
|
|
40
|
+
return `✏️ **Write** ${input.file_path || input.path || ''}`;
|
|
41
|
+
case 'Edit':
|
|
42
|
+
return `🔧 **Edit** ${input.file_path || input.path || ''}`;
|
|
43
|
+
case 'Bash':
|
|
44
|
+
case 'Terminal':
|
|
45
|
+
return `💻 **Bash** \`${String(input.command || input.cmd || '').slice(0, 80)}\``;
|
|
46
|
+
case 'Grep':
|
|
47
|
+
case 'Search':
|
|
48
|
+
return `🔍 **Search** \`${input.pattern || input.query || ''}\``;
|
|
49
|
+
case 'Glob':
|
|
50
|
+
case 'ListFiles':
|
|
51
|
+
return `📁 **Glob** \`${input.pattern || input.path || ''}\``;
|
|
52
|
+
case 'AskUserQuestion':
|
|
53
|
+
return `❓ **Question**: ${input.question || ''}`;
|
|
54
|
+
case 'TodoWrite':
|
|
55
|
+
return `📝 **Todo** updated`;
|
|
56
|
+
default:
|
|
57
|
+
return `🔧 **${name}**`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Parse entries from JSONL data. */
|
|
61
|
+
export function parseEntries(entries) {
|
|
62
|
+
const results = [];
|
|
63
|
+
const pendingTools = new Map(); // tool_use_id -> summary
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.type === 'progress') {
|
|
66
|
+
const text = entry.data ? JSON.stringify(entry.data) : '';
|
|
67
|
+
if (text.trim()) {
|
|
68
|
+
results.push({ role: 'system', contentType: 'progress', text, timestamp: entry.timestamp });
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!entry.message)
|
|
73
|
+
continue;
|
|
74
|
+
const role = entry.message.role;
|
|
75
|
+
const content = entry.message.content;
|
|
76
|
+
const timestamp = entry.timestamp;
|
|
77
|
+
if (typeof content === 'string') {
|
|
78
|
+
// Simple text message
|
|
79
|
+
if (content.trim()) {
|
|
80
|
+
results.push({ role, contentType: 'text', text: content.trim(), timestamp });
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(content))
|
|
85
|
+
continue;
|
|
86
|
+
for (const block of content) {
|
|
87
|
+
switch (block.type) {
|
|
88
|
+
case 'text':
|
|
89
|
+
if (block.text?.trim()) {
|
|
90
|
+
results.push({ role, contentType: 'text', text: block.text.trim(), timestamp });
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case 'thinking':
|
|
94
|
+
if (block.thinking?.trim()) {
|
|
95
|
+
results.push({ role, contentType: 'thinking', text: block.thinking.trim(), timestamp });
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case 'tool_use': {
|
|
99
|
+
const name = block.name || 'unknown';
|
|
100
|
+
const input = (block.input || {});
|
|
101
|
+
const summary = summarizeTool(name, input);
|
|
102
|
+
if (block.id) {
|
|
103
|
+
pendingTools.set(block.id, summary);
|
|
104
|
+
}
|
|
105
|
+
results.push({
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
contentType: 'tool_use',
|
|
108
|
+
text: summary,
|
|
109
|
+
toolName: name,
|
|
110
|
+
toolUseId: block.id,
|
|
111
|
+
timestamp,
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'tool_result': {
|
|
116
|
+
const toolId = block.tool_use_id || '';
|
|
117
|
+
let resultText = '';
|
|
118
|
+
if (typeof block.content === 'string') {
|
|
119
|
+
resultText = block.content;
|
|
120
|
+
}
|
|
121
|
+
else if (Array.isArray(block.content)) {
|
|
122
|
+
resultText = block.content
|
|
123
|
+
.filter(c => c.type === 'text')
|
|
124
|
+
.map(c => c.text || '')
|
|
125
|
+
.join('\n');
|
|
126
|
+
}
|
|
127
|
+
// Truncate long results
|
|
128
|
+
if (resultText.length > 500) {
|
|
129
|
+
resultText = resultText.slice(0, 500) + '... (truncated)';
|
|
130
|
+
}
|
|
131
|
+
if (resultText.trim()) {
|
|
132
|
+
results.push({
|
|
133
|
+
role: 'assistant',
|
|
134
|
+
contentType: block.is_error ? 'tool_error' : 'tool_result',
|
|
135
|
+
text: resultText.trim(),
|
|
136
|
+
toolUseId: toolId,
|
|
137
|
+
timestamp,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
pendingTools.delete(toolId);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 'permission_request': {
|
|
144
|
+
const permText = block.text || JSON.stringify(block);
|
|
145
|
+
if (permText.trim()) {
|
|
146
|
+
results.push({
|
|
147
|
+
role: 'user',
|
|
148
|
+
contentType: 'permission_request',
|
|
149
|
+
text: permText.trim(),
|
|
150
|
+
timestamp,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return results;
|
|
159
|
+
}
|
|
160
|
+
/** Issue #488: Sum token usage across a batch of raw JSONL entries. */
|
|
161
|
+
export function extractTokenDelta(raw) {
|
|
162
|
+
let inputTokens = 0;
|
|
163
|
+
let outputTokens = 0;
|
|
164
|
+
let cacheCreationTokens = 0;
|
|
165
|
+
let cacheReadTokens = 0;
|
|
166
|
+
for (const entry of raw) {
|
|
167
|
+
const u = entry.message?.usage;
|
|
168
|
+
if (!u)
|
|
169
|
+
continue;
|
|
170
|
+
inputTokens += u.input_tokens ?? 0;
|
|
171
|
+
outputTokens += u.output_tokens ?? 0;
|
|
172
|
+
cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
|
|
173
|
+
cacheReadTokens += u.cache_read_input_tokens ?? 0;
|
|
174
|
+
}
|
|
175
|
+
return { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens };
|
|
176
|
+
}
|
|
177
|
+
/** Read JSONL file from byte offset, return new entries + new offset. */
|
|
178
|
+
export async function readNewEntries(filePath, fromOffset) {
|
|
179
|
+
// Issue #623: Use a single fd for stat + read to eliminate TOCTOU race.
|
|
180
|
+
const fd = await open(filePath, 'r');
|
|
181
|
+
try {
|
|
182
|
+
const fileStat = await fd.stat();
|
|
183
|
+
// File truncated (e.g. after /clear)
|
|
184
|
+
if (fromOffset > fileStat.size) {
|
|
185
|
+
return { entries: [], newOffset: 0, raw: [] };
|
|
186
|
+
}
|
|
187
|
+
if (fromOffset >= fileStat.size) {
|
|
188
|
+
return { entries: [], newOffset: fromOffset, raw: [] };
|
|
189
|
+
}
|
|
190
|
+
// Read from byte offset to end using createReadStream to avoid loading entire file
|
|
191
|
+
// Issue #222: Only read from offset forward, not the whole file
|
|
192
|
+
// Issue #259: If offset lands mid-entry, scan backwards to previous newline
|
|
193
|
+
// Issue #409: Use async I/O instead of readFileSync to avoid blocking the event loop
|
|
194
|
+
let effectiveOffset = fromOffset;
|
|
195
|
+
if (effectiveOffset > 0) {
|
|
196
|
+
const scanSize = 4096;
|
|
197
|
+
const scanStart = Math.max(0, effectiveOffset - scanSize);
|
|
198
|
+
const scanLen = effectiveOffset - scanStart;
|
|
199
|
+
const scanBuf = Buffer.alloc(scanLen);
|
|
200
|
+
await fd.read(scanBuf, 0, scanLen, scanStart);
|
|
201
|
+
let foundNewline = false;
|
|
202
|
+
for (let i = scanBuf.length - 1; i >= 0; i--) {
|
|
203
|
+
if (scanBuf[i] === 0x0a) { // '\n'
|
|
204
|
+
effectiveOffset = scanStart + i + 1;
|
|
205
|
+
foundNewline = true;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Issue #836: If no newline found in the scan window, the line is
|
|
210
|
+
// longer than scanSize. Keep effectiveOffset as-is (fromOffset) —
|
|
211
|
+
// starting mid-line is handled by JSON.parse rejecting partial lines.
|
|
212
|
+
// Never fall back to offset 0, which causes O(n) re-reads.
|
|
213
|
+
}
|
|
214
|
+
const slicedContent = await new Promise((resolve, reject) => {
|
|
215
|
+
const chunks = [];
|
|
216
|
+
// Reuse the same fd — autoClose: false because we close it in the outer finally
|
|
217
|
+
const stream = createReadStream(filePath, { fd: fd.fd, start: effectiveOffset, autoClose: false });
|
|
218
|
+
stream.on('data', (chunk) => { if (typeof chunk !== 'string')
|
|
219
|
+
chunks.push(chunk); });
|
|
220
|
+
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
221
|
+
stream.on('error', reject);
|
|
222
|
+
});
|
|
223
|
+
const lines = slicedContent.split('\n');
|
|
224
|
+
const rawEntries = [];
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
const entry = parseLine(line);
|
|
227
|
+
if (entry) {
|
|
228
|
+
rawEntries.push(entry);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const parsed = parseEntries(rawEntries);
|
|
232
|
+
return { entries: parsed, newOffset: fileStat.size, raw: rawEntries };
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await fd.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Check if a path exists (async). Issue #658: replaces sync existsSync. */
|
|
239
|
+
async function pathExists(filePath) {
|
|
240
|
+
try {
|
|
241
|
+
await access(filePath);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Find the JSONL file for a session ID. */
|
|
249
|
+
export async function findSessionFile(sessionId, claudeProjectsDir = DEFAULT_CLAUDE_PROJECTS_DIR) {
|
|
250
|
+
const projectsDir = claudeProjectsDir;
|
|
251
|
+
if (!(await pathExists(projectsDir)))
|
|
252
|
+
return null;
|
|
253
|
+
// Strategy 1: Direct glob across all project dirs
|
|
254
|
+
const dirs = await readdir(projectsDir, { withFileTypes: true });
|
|
255
|
+
for (const dir of dirs) {
|
|
256
|
+
if (!dir.isDirectory())
|
|
257
|
+
continue;
|
|
258
|
+
const jsonlPath = join(projectsDir, dir.name, `${sessionId}.jsonl`);
|
|
259
|
+
if (await pathExists(jsonlPath))
|
|
260
|
+
return jsonlPath;
|
|
261
|
+
}
|
|
262
|
+
// Strategy 2: Check sessions-index.json files
|
|
263
|
+
for (const dir of dirs) {
|
|
264
|
+
if (!dir.isDirectory())
|
|
265
|
+
continue;
|
|
266
|
+
const indexPath = join(projectsDir, dir.name, 'sessions-index.json');
|
|
267
|
+
if (await pathExists(indexPath)) {
|
|
268
|
+
try {
|
|
269
|
+
const indexRaw = await readFile(indexPath, 'utf-8');
|
|
270
|
+
const indexParsed = safeJsonParseSchema(indexRaw, sessionsIndexSchema, 'sessions-index.json');
|
|
271
|
+
if (!indexParsed.ok)
|
|
272
|
+
continue;
|
|
273
|
+
const entries = indexParsed.data.entries || [];
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
if (entry.sessionId === sessionId && entry.fullPath && (await pathExists(entry.fullPath))) {
|
|
276
|
+
return entry.fullPath;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch { /* skip bad index */ }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class CircularBuffer {
|
|
2
|
+
capacity;
|
|
3
|
+
items;
|
|
4
|
+
head = 0;
|
|
5
|
+
count = 0;
|
|
6
|
+
constructor(capacity) {
|
|
7
|
+
this.capacity = capacity;
|
|
8
|
+
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
9
|
+
throw new Error(`CircularBuffer capacity must be a positive integer, got: ${capacity}`);
|
|
10
|
+
}
|
|
11
|
+
this.items = new Array(capacity);
|
|
12
|
+
}
|
|
13
|
+
push(item) {
|
|
14
|
+
this.items[this.head] = item;
|
|
15
|
+
this.head = (this.head + 1) % this.capacity;
|
|
16
|
+
if (this.count < this.capacity) {
|
|
17
|
+
this.count++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
toArray() {
|
|
21
|
+
if (this.count === 0) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
if (this.count < this.capacity) {
|
|
25
|
+
return this.items.slice(0, this.count);
|
|
26
|
+
}
|
|
27
|
+
return [...this.items.slice(this.head), ...this.items.slice(0, this.head)];
|
|
28
|
+
}
|
|
29
|
+
clear() {
|
|
30
|
+
this.items.fill(undefined);
|
|
31
|
+
this.head = 0;
|
|
32
|
+
this.count = 0;
|
|
33
|
+
}
|
|
34
|
+
size() {
|
|
35
|
+
return this.count;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact-headers.ts — Redact sensitive header values for safe logging.
|
|
3
|
+
*
|
|
4
|
+
* Issue #582: Prevent webhook custom headers (Authorization, Cookie, etc.)
|
|
5
|
+
* from leaking into error logs on delivery failures.
|
|
6
|
+
*/
|
|
7
|
+
/** Return a copy of `headers` with sensitive values replaced. */
|
|
8
|
+
export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* Scrub any sensitive header *values* from arbitrary text.
|
|
11
|
+
* If a fetch error message happens to include a header value, this removes it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function redactSecretsFromText(text: string, headers: Record<string, string> | undefined): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact-headers.ts — Redact sensitive header values for safe logging.
|
|
3
|
+
*
|
|
4
|
+
* Issue #582: Prevent webhook custom headers (Authorization, Cookie, etc.)
|
|
5
|
+
* from leaking into error logs on delivery failures.
|
|
6
|
+
*/
|
|
7
|
+
/** Header names whose values should be treated as secrets. Case-insensitive. */
|
|
8
|
+
const SENSITIVE_HEADER_NAMES = new Set([
|
|
9
|
+
'authorization',
|
|
10
|
+
'cookie',
|
|
11
|
+
'set-cookie',
|
|
12
|
+
'x-api-key',
|
|
13
|
+
'x-auth-token',
|
|
14
|
+
'api-key',
|
|
15
|
+
'apikey',
|
|
16
|
+
'proxy-authorization',
|
|
17
|
+
'x-csrf-token',
|
|
18
|
+
'www-authenticate',
|
|
19
|
+
'proxy-authenticate',
|
|
20
|
+
]);
|
|
21
|
+
function isSensitive(headerName) {
|
|
22
|
+
return SENSITIVE_HEADER_NAMES.has(headerName.toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
function redactValue(value) {
|
|
25
|
+
if (value.length <= 8)
|
|
26
|
+
return '[REDACTED]';
|
|
27
|
+
return `${value.slice(0, 4)}...[REDACTED]`;
|
|
28
|
+
}
|
|
29
|
+
/** Return a copy of `headers` with sensitive values replaced. */
|
|
30
|
+
export function redactHeaders(headers) {
|
|
31
|
+
const result = {};
|
|
32
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
33
|
+
result[name] = isSensitive(name) ? redactValue(value) : value;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Scrub any sensitive header *values* from arbitrary text.
|
|
39
|
+
* If a fetch error message happens to include a header value, this removes it.
|
|
40
|
+
*/
|
|
41
|
+
export function redactSecretsFromText(text, headers) {
|
|
42
|
+
if (!headers)
|
|
43
|
+
return text;
|
|
44
|
+
let result = text;
|
|
45
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
46
|
+
if (!isSensitive(name) || !value)
|
|
47
|
+
continue;
|
|
48
|
+
// Skip very short values — too many false positives
|
|
49
|
+
if (value.length < 4)
|
|
50
|
+
continue;
|
|
51
|
+
result = result.replaceAll(value, '[REDACTED]');
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|