@archznn/crewloop-skills 0.3.0 → 0.4.1
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/package.json +16 -3
- package/packages/cli/README.md +55 -0
- package/packages/cli/bin/crewloop.js +6 -0
- package/packages/cli/dist/agents.d.ts +7 -0
- package/packages/cli/dist/agents.d.ts.map +1 -0
- package/packages/cli/dist/agents.js +31 -0
- package/packages/cli/dist/agents.js.map +1 -0
- package/packages/cli/dist/cli.d.ts +14 -0
- package/packages/cli/dist/cli.d.ts.map +1 -0
- package/packages/cli/dist/cli.js +255 -0
- package/packages/cli/dist/cli.js.map +1 -0
- package/packages/cli/dist/installer.d.ts +19 -0
- package/packages/cli/dist/installer.d.ts.map +1 -0
- package/packages/cli/dist/installer.js +89 -0
- package/packages/cli/dist/installer.js.map +1 -0
- package/packages/cli/dist/mcp.d.ts +20 -0
- package/packages/cli/dist/mcp.d.ts.map +1 -0
- package/packages/cli/dist/mcp.js +130 -0
- package/packages/cli/dist/mcp.js.map +1 -0
- package/packages/cli/dist/resolver.d.ts +7 -0
- package/packages/cli/dist/resolver.d.ts.map +1 -0
- package/packages/cli/dist/resolver.js +57 -0
- package/packages/cli/dist/resolver.js.map +1 -0
- package/packages/cli/dist/tests/agents.test.d.ts +2 -0
- package/packages/cli/dist/tests/agents.test.d.ts.map +1 -0
- package/packages/cli/dist/tests/agents.test.js +27 -0
- package/packages/cli/dist/tests/agents.test.js.map +1 -0
- package/packages/cli/dist/tests/cli.test.d.ts +2 -0
- package/packages/cli/dist/tests/cli.test.d.ts.map +1 -0
- package/packages/cli/dist/tests/cli.test.js +103 -0
- package/packages/cli/dist/tests/cli.test.js.map +1 -0
- package/packages/cli/dist/tests/installer.test.d.ts +2 -0
- package/packages/cli/dist/tests/installer.test.d.ts.map +1 -0
- package/packages/cli/dist/tests/installer.test.js +129 -0
- package/packages/cli/dist/tests/installer.test.js.map +1 -0
- package/packages/cli/dist/tests/mcp.test.d.ts +2 -0
- package/packages/cli/dist/tests/mcp.test.d.ts.map +1 -0
- package/packages/cli/dist/tests/mcp.test.js +153 -0
- package/packages/cli/dist/tests/mcp.test.js.map +1 -0
- package/packages/cli/dist/tests/resolver.test.d.ts +2 -0
- package/packages/cli/dist/tests/resolver.test.d.ts.map +1 -0
- package/packages/cli/dist/tests/resolver.test.js +41 -0
- package/packages/cli/dist/tests/resolver.test.js.map +1 -0
- package/servers/dashboard/README.md +87 -0
- package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
- package/servers/dashboard/config-examples/codex-hooks.json +14 -0
- package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
- package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
- package/servers/dashboard/dist/adapters/codex.d.ts +23 -0
- package/servers/dashboard/dist/adapters/codex.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/codex.js +28 -0
- package/servers/dashboard/dist/adapters/codex.js.map +1 -0
- package/servers/dashboard/dist/adapters/kimi.d.ts +13 -0
- package/servers/dashboard/dist/adapters/kimi.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/kimi.js +28 -0
- package/servers/dashboard/dist/adapters/kimi.js.map +1 -0
- package/servers/dashboard/dist/adapters/opencode.d.ts +9 -0
- package/servers/dashboard/dist/adapters/opencode.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/opencode.js +26 -0
- package/servers/dashboard/dist/adapters/opencode.js.map +1 -0
- package/servers/dashboard/dist/adapters/shim.d.ts +7 -0
- package/servers/dashboard/dist/adapters/shim.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/shim.js +107 -0
- package/servers/dashboard/dist/adapters/shim.js.map +1 -0
- package/servers/dashboard/dist/adapters/shim.test.d.ts +2 -0
- package/servers/dashboard/dist/adapters/shim.test.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/shim.test.js +72 -0
- package/servers/dashboard/dist/adapters/shim.test.js.map +1 -0
- package/servers/dashboard/dist/api/event.d.ts +13 -0
- package/servers/dashboard/dist/api/event.d.ts.map +1 -0
- package/servers/dashboard/dist/api/event.js +52 -0
- package/servers/dashboard/dist/api/event.js.map +1 -0
- package/servers/dashboard/dist/api/skills.d.ts +4 -0
- package/servers/dashboard/dist/api/skills.d.ts.map +1 -0
- package/servers/dashboard/dist/api/skills.js +12 -0
- package/servers/dashboard/dist/api/skills.js.map +1 -0
- package/servers/dashboard/dist/config.d.ts +11 -0
- package/servers/dashboard/dist/config.d.ts.map +1 -0
- package/servers/dashboard/dist/config.js +65 -0
- package/servers/dashboard/dist/config.js.map +1 -0
- package/servers/dashboard/dist/filters/sanitize.d.ts +14 -0
- package/servers/dashboard/dist/filters/sanitize.d.ts.map +1 -0
- package/servers/dashboard/dist/filters/sanitize.js +64 -0
- package/servers/dashboard/dist/filters/sanitize.js.map +1 -0
- package/servers/dashboard/dist/filters/sanitize.test.d.ts +2 -0
- package/servers/dashboard/dist/filters/sanitize.test.d.ts.map +1 -0
- package/servers/dashboard/dist/filters/sanitize.test.js +71 -0
- package/servers/dashboard/dist/filters/sanitize.test.js.map +1 -0
- package/servers/dashboard/dist/index.d.ts +2 -0
- package/servers/dashboard/dist/index.d.ts.map +1 -0
- package/servers/dashboard/dist/index.js +22 -0
- package/servers/dashboard/dist/index.js.map +1 -0
- package/servers/dashboard/dist/presenter.d.ts +7 -0
- package/servers/dashboard/dist/presenter.d.ts.map +1 -0
- package/servers/dashboard/dist/presenter.js +54 -0
- package/servers/dashboard/dist/presenter.js.map +1 -0
- package/servers/dashboard/dist/presenter.test.d.ts +2 -0
- package/servers/dashboard/dist/presenter.test.d.ts.map +1 -0
- package/servers/dashboard/dist/presenter.test.js +66 -0
- package/servers/dashboard/dist/presenter.test.js.map +1 -0
- package/servers/dashboard/dist/server.d.ts +13 -0
- package/servers/dashboard/dist/server.d.ts.map +1 -0
- package/servers/dashboard/dist/server.js +162 -0
- package/servers/dashboard/dist/server.js.map +1 -0
- package/servers/dashboard/dist/server.test.d.ts +2 -0
- package/servers/dashboard/dist/server.test.d.ts.map +1 -0
- package/servers/dashboard/dist/server.test.js +113 -0
- package/servers/dashboard/dist/server.test.js.map +1 -0
- package/servers/dashboard/dist/skills/infer.d.ts +8 -0
- package/servers/dashboard/dist/skills/infer.d.ts.map +1 -0
- package/servers/dashboard/dist/skills/infer.js +48 -0
- package/servers/dashboard/dist/skills/infer.js.map +1 -0
- package/servers/dashboard/dist/skills/infer.test.d.ts +2 -0
- package/servers/dashboard/dist/skills/infer.test.d.ts.map +1 -0
- package/servers/dashboard/dist/skills/infer.test.js +82 -0
- package/servers/dashboard/dist/skills/infer.test.js.map +1 -0
- package/servers/dashboard/dist/skills/mapping.d.ts +5 -0
- package/servers/dashboard/dist/skills/mapping.d.ts.map +1 -0
- package/servers/dashboard/dist/skills/mapping.js +28 -0
- package/servers/dashboard/dist/skills/mapping.js.map +1 -0
- package/servers/dashboard/dist/skills/registry.d.ts +11 -0
- package/servers/dashboard/dist/skills/registry.d.ts.map +1 -0
- package/servers/dashboard/dist/skills/registry.js +59 -0
- package/servers/dashboard/dist/skills/registry.js.map +1 -0
- package/servers/dashboard/dist/state.d.ts +18 -0
- package/servers/dashboard/dist/state.d.ts.map +1 -0
- package/servers/dashboard/dist/state.js +91 -0
- package/servers/dashboard/dist/state.js.map +1 -0
- package/servers/dashboard/dist/state.test.d.ts +2 -0
- package/servers/dashboard/dist/state.test.d.ts.map +1 -0
- package/servers/dashboard/dist/state.test.js +83 -0
- package/servers/dashboard/dist/state.test.js.map +1 -0
- package/servers/dashboard/dist/types.d.ts +86 -0
- package/servers/dashboard/dist/types.d.ts.map +1 -0
- package/servers/dashboard/dist/types.js +3 -0
- package/servers/dashboard/dist/types.js.map +1 -0
- package/servers/dashboard/package.json +46 -0
- package/servers/dashboard/public/app.js +447 -0
- package/servers/dashboard/public/index.html +96 -0
- package/servers/dashboard/public/styles.css +664 -0
- package/servers/dashboard/src/adapters/codex.ts +50 -0
- package/servers/dashboard/src/adapters/kimi.ts +40 -0
- package/servers/dashboard/src/adapters/opencode.ts +36 -0
- package/servers/dashboard/src/adapters/shim.test.ts +74 -0
- package/servers/dashboard/src/adapters/shim.ts +120 -0
- package/servers/dashboard/src/api/event.ts +70 -0
- package/servers/dashboard/src/api/skills.ts +11 -0
- package/servers/dashboard/src/config.ts +66 -0
- package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
- package/servers/dashboard/src/filters/sanitize.ts +78 -0
- package/servers/dashboard/src/index.ts +24 -0
- package/servers/dashboard/src/presenter.test.ts +69 -0
- package/servers/dashboard/src/presenter.ts +56 -0
- package/servers/dashboard/src/server.test.ts +123 -0
- package/servers/dashboard/src/server.ts +191 -0
- package/servers/dashboard/src/skills/infer.test.ts +86 -0
- package/servers/dashboard/src/skills/infer.ts +53 -0
- package/servers/dashboard/src/skills/mapping.ts +26 -0
- package/servers/dashboard/src/skills/registry.ts +60 -0
- package/servers/dashboard/src/state.test.ts +88 -0
- package/servers/dashboard/src/state.ts +115 -0
- package/servers/dashboard/src/types.ts +110 -0
- package/servers/dashboard/tsconfig.json +19 -0
- package/skills/orchestrator/SKILL.md +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentSource, DashboardEvent, EventType } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface KimiHookPayload {
|
|
4
|
+
hook_event_name: string;
|
|
5
|
+
session_id: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
tool_name?: string;
|
|
8
|
+
tool_input?: Record<string, unknown>;
|
|
9
|
+
tool_response?: Record<string, unknown>;
|
|
10
|
+
stop_reason?: string;
|
|
11
|
+
usage?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EVENT_MAP: Record<string, EventType> = {
|
|
15
|
+
PreToolUse: 'tool_start',
|
|
16
|
+
PostToolUse: 'tool_end',
|
|
17
|
+
SessionStart: 'session_start',
|
|
18
|
+
SessionEnd: 'session_end',
|
|
19
|
+
Stop: 'session_end',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function normalizeKimi(payload: KimiHookPayload): DashboardEvent | undefined {
|
|
23
|
+
const event_type = EVENT_MAP[payload.hook_event_name];
|
|
24
|
+
if (!event_type) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
id: generateId(),
|
|
30
|
+
timestamp: Date.now(),
|
|
31
|
+
source: 'kimi' as AgentSource,
|
|
32
|
+
session_id: payload.session_id || 'unknown',
|
|
33
|
+
event_type,
|
|
34
|
+
tool: payload.tool_name,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generateId(): string {
|
|
39
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AgentSource, DashboardEvent, EventType, EventStatus } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface OpenCodeToolEvent {
|
|
4
|
+
tool: string;
|
|
5
|
+
args?: Record<string, unknown>;
|
|
6
|
+
duration?: number;
|
|
7
|
+
success?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createOpenCodeEvent(
|
|
11
|
+
sessionId: string,
|
|
12
|
+
eventType: EventType,
|
|
13
|
+
data: OpenCodeToolEvent
|
|
14
|
+
): DashboardEvent {
|
|
15
|
+
let status: EventStatus | undefined;
|
|
16
|
+
if (eventType === 'tool_end') {
|
|
17
|
+
status = data.success === false ? 'error' : 'success';
|
|
18
|
+
} else if (eventType === 'tool_start') {
|
|
19
|
+
status = 'running';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
id: generateId(),
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
source: 'opencode' as AgentSource,
|
|
26
|
+
session_id: sessionId,
|
|
27
|
+
event_type: eventType,
|
|
28
|
+
tool: data.tool,
|
|
29
|
+
status,
|
|
30
|
+
duration_ms: data.duration,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateId(): string {
|
|
35
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { detectSource, buildEvent } from './shim';
|
|
4
|
+
import type { AgentSource, DashboardEvent } from '../types';
|
|
5
|
+
|
|
6
|
+
describe('detectSource', () => {
|
|
7
|
+
it('detects source from argv', () => {
|
|
8
|
+
assert.equal(detectSource(['node', 'shim', 'kimi']), 'kimi');
|
|
9
|
+
assert.equal(detectSource(['node', 'shim', 'codex']), 'codex');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('falls back to env var', () => {
|
|
13
|
+
process.env.CREWLOOP_DASHBOARD_SOURCE = 'opencode';
|
|
14
|
+
assert.equal(detectSource(['node', 'shim']), 'opencode');
|
|
15
|
+
delete process.env.CREWLOOP_DASHBOARD_SOURCE;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns undefined when unknown', () => {
|
|
19
|
+
assert.equal(detectSource(['node', 'shim', 'unknown']), undefined);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('buildEvent', () => {
|
|
24
|
+
it('builds Kimi PreToolUse event', () => {
|
|
25
|
+
const event = buildEvent('kimi' as AgentSource, {
|
|
26
|
+
hook_event_name: 'PreToolUse',
|
|
27
|
+
session_id: 'sess-1',
|
|
28
|
+
cwd: '/project',
|
|
29
|
+
tool_name: 'Read',
|
|
30
|
+
tool_input: { path: 'README.md' },
|
|
31
|
+
});
|
|
32
|
+
assert.equal(event?.event_type, 'tool_start');
|
|
33
|
+
assert.equal(event?.tool, 'Read');
|
|
34
|
+
assert.equal(event?.detail, 'README.md');
|
|
35
|
+
assert.equal(event?.status, 'running');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('builds Kimi PostToolUse event with status', () => {
|
|
39
|
+
const event = buildEvent('kimi' as AgentSource, {
|
|
40
|
+
hook_event_name: 'PostToolUse',
|
|
41
|
+
session_id: 'sess-1',
|
|
42
|
+
cwd: '/project',
|
|
43
|
+
tool_name: 'Read',
|
|
44
|
+
tool_input: { path: 'README.md' },
|
|
45
|
+
tool_response: { success: true, duration_ms: 12 },
|
|
46
|
+
});
|
|
47
|
+
assert.equal(event?.event_type, 'tool_end');
|
|
48
|
+
assert.equal(event?.status, 'success');
|
|
49
|
+
assert.equal(event?.duration_ms, 12);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('builds Codex event', () => {
|
|
53
|
+
const event = buildEvent('codex' as AgentSource, {
|
|
54
|
+
sessionId: 'sess-2',
|
|
55
|
+
toolName: 'Bash',
|
|
56
|
+
toolInput: { command: 'ls' },
|
|
57
|
+
toolResponse: { success: true, durationMs: 5 },
|
|
58
|
+
});
|
|
59
|
+
assert.equal(event?.source, 'codex');
|
|
60
|
+
assert.equal(event?.tool, 'Bash');
|
|
61
|
+
assert.equal(event?.detail, undefined);
|
|
62
|
+
assert.equal(event?.status, 'success');
|
|
63
|
+
assert.equal(event?.duration_ms, 5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns undefined for unsupported hook event', () => {
|
|
67
|
+
const event = buildEvent('kimi' as AgentSource, {
|
|
68
|
+
hook_event_name: 'UnknownEvent',
|
|
69
|
+
session_id: 'sess-1',
|
|
70
|
+
cwd: '/project',
|
|
71
|
+
});
|
|
72
|
+
assert.equal(event, undefined);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import type { AgentSource, DashboardEvent } from '../types';
|
|
3
|
+
import { normalizeKimi, type KimiHookPayload } from './kimi';
|
|
4
|
+
import { normalizeCodex, type CodexHookPayload } from './codex';
|
|
5
|
+
import { sanitize } from '../filters/sanitize';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SERVER_URL = 'http://127.0.0.1:7890';
|
|
8
|
+
|
|
9
|
+
export function detectSource(argv: string[]): AgentSource | undefined {
|
|
10
|
+
const arg = argv[2];
|
|
11
|
+
if (arg === 'kimi' || arg === 'codex' || arg === 'opencode' || arg === 'log-watcher') {
|
|
12
|
+
return arg;
|
|
13
|
+
}
|
|
14
|
+
const env = process.env.CREWLOOP_DASHBOARD_SOURCE;
|
|
15
|
+
if (
|
|
16
|
+
env === 'kimi' ||
|
|
17
|
+
env === 'codex' ||
|
|
18
|
+
env === 'opencode' ||
|
|
19
|
+
env === 'log-watcher'
|
|
20
|
+
) {
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizePayload(source: AgentSource, raw: unknown): DashboardEvent | undefined {
|
|
27
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const payload = raw as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
switch (source) {
|
|
34
|
+
case 'kimi':
|
|
35
|
+
return normalizeKimi(payload as unknown as KimiHookPayload);
|
|
36
|
+
case 'codex':
|
|
37
|
+
return normalizeCodex(payload as unknown as CodexHookPayload);
|
|
38
|
+
default:
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildEvent(
|
|
44
|
+
source: AgentSource,
|
|
45
|
+
raw: Record<string, unknown>
|
|
46
|
+
): DashboardEvent | undefined {
|
|
47
|
+
const base = normalizePayload(source, raw);
|
|
48
|
+
if (!base) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isPost = base.event_type === 'tool_end';
|
|
53
|
+
const sanitized = sanitize(
|
|
54
|
+
{
|
|
55
|
+
tool_name: base.tool || '',
|
|
56
|
+
tool_input: (raw.tool_input || raw.toolInput) as Record<string, unknown> | undefined,
|
|
57
|
+
tool_response: (raw.tool_response || raw.toolResponse) as Record<string, unknown> | undefined,
|
|
58
|
+
},
|
|
59
|
+
isPost ? 'post' : 'pre'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
detail: sanitized.detail,
|
|
65
|
+
status: sanitized.status,
|
|
66
|
+
duration_ms: sanitized.duration_ms,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function postEvent(event: DashboardEvent): void {
|
|
71
|
+
const serverUrl = process.env.CREWLOOP_DASHBOARD_URL || DEFAULT_SERVER_URL;
|
|
72
|
+
const body = JSON.stringify(event);
|
|
73
|
+
|
|
74
|
+
const url = new URL('/event', serverUrl);
|
|
75
|
+
const req = http.request(
|
|
76
|
+
{
|
|
77
|
+
hostname: url.hostname,
|
|
78
|
+
port: url.port,
|
|
79
|
+
path: url.pathname,
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
'Content-Length': Buffer.byteLength(body),
|
|
84
|
+
},
|
|
85
|
+
timeout: 300,
|
|
86
|
+
},
|
|
87
|
+
() => {}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
req.on('error', () => {});
|
|
91
|
+
req.on('timeout', () => req.destroy());
|
|
92
|
+
req.write(body);
|
|
93
|
+
req.end();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function runShim(): void {
|
|
97
|
+
const source = detectSource(process.argv);
|
|
98
|
+
if (!source) {
|
|
99
|
+
process.stderr.write('crewloop-shim: unknown source. Use: crewloop-shim <kimi|codex>\n');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let raw = '';
|
|
104
|
+
process.stdin.setEncoding('utf8');
|
|
105
|
+
process.stdin.on('data', (chunk) => {
|
|
106
|
+
raw += chunk;
|
|
107
|
+
});
|
|
108
|
+
process.stdin.on('end', () => {
|
|
109
|
+
try {
|
|
110
|
+
const payload = JSON.parse(raw);
|
|
111
|
+
const event = buildEvent(source, payload);
|
|
112
|
+
if (event) {
|
|
113
|
+
postEvent(event);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Fail silently so the agent is never blocked.
|
|
117
|
+
}
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { DashboardEvent, ClientWebSocketMessage } from '../types';
|
|
3
|
+
import { StateStore } from '../state';
|
|
4
|
+
import { SkillInferenceEngine } from '../skills/infer';
|
|
5
|
+
import { sanitizeEventBoundary } from '../filters/sanitize';
|
|
6
|
+
import { createUpdateMessage } from '../presenter';
|
|
7
|
+
|
|
8
|
+
export interface EventHandlerDependencies {
|
|
9
|
+
state: StateStore;
|
|
10
|
+
inference: SkillInferenceEngine;
|
|
11
|
+
broadcast: (message: ClientWebSocketMessage) => void;
|
|
12
|
+
getActiveSessionId: () => string | undefined;
|
|
13
|
+
setActiveSessionId: (id: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createEventHandler(deps: EventHandlerDependencies) {
|
|
17
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
18
|
+
if (req.method !== 'POST') {
|
|
19
|
+
res.statusCode = 405;
|
|
20
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let body = '';
|
|
25
|
+
req.setEncoding('utf8');
|
|
26
|
+
req.on('data', (chunk) => {
|
|
27
|
+
body += chunk;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await new Promise<void>((resolve) => {
|
|
31
|
+
req.on('end', resolve);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let event: DashboardEvent;
|
|
35
|
+
try {
|
|
36
|
+
event = JSON.parse(body) as DashboardEvent;
|
|
37
|
+
} catch {
|
|
38
|
+
res.statusCode = 400;
|
|
39
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!sanitizeEventBoundary(event as unknown as Record<string, unknown>)) {
|
|
44
|
+
res.statusCode = 400;
|
|
45
|
+
res.end(JSON.stringify({ error: 'Event contains unsafe fields' }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!event.session_id || !event.event_type) {
|
|
50
|
+
res.statusCode = 400;
|
|
51
|
+
res.end(JSON.stringify({ error: 'Missing required fields' }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const session = deps.state.applyEvent(event);
|
|
56
|
+
const inferred = deps.inference.infer(event, session);
|
|
57
|
+
|
|
58
|
+
if (inferred.skill !== session.active_skill || inferred.confidence !== session.active_confidence) {
|
|
59
|
+
deps.state.setActiveSkill(session.id, inferred.skill, inferred.confidence);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const updatedSession = deps.state.getSession(session.id)!;
|
|
63
|
+
deps.setActiveSessionId(updatedSession.id);
|
|
64
|
+
|
|
65
|
+
deps.broadcast(createUpdateMessage(updatedSession, deps.getActiveSessionId()));
|
|
66
|
+
|
|
67
|
+
res.statusCode = 200;
|
|
68
|
+
res.end(JSON.stringify({ ok: true }));
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { SkillRegistry } from '../skills/registry';
|
|
3
|
+
|
|
4
|
+
export function createSkillsHandler(registry: SkillRegistry) {
|
|
5
|
+
return async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
6
|
+
const skills = registry.getSkills();
|
|
7
|
+
res.statusCode = 200;
|
|
8
|
+
res.setHeader('Content-Type', 'application/json');
|
|
9
|
+
res.end(JSON.stringify(skills));
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import type { ServerConfig } from './types';
|
|
4
|
+
|
|
5
|
+
export const SAFE_TOOL_INPUT_KEYS = new Set([
|
|
6
|
+
'path',
|
|
7
|
+
'file_path',
|
|
8
|
+
'skill',
|
|
9
|
+
'subagent_type',
|
|
10
|
+
'url',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const DANGEROUS_TOOL_INPUT_KEYS = new Set([
|
|
14
|
+
'command',
|
|
15
|
+
'content',
|
|
16
|
+
'text',
|
|
17
|
+
'code',
|
|
18
|
+
'prompt',
|
|
19
|
+
'api_key',
|
|
20
|
+
'token',
|
|
21
|
+
'password',
|
|
22
|
+
'secret',
|
|
23
|
+
'key',
|
|
24
|
+
'authorization',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_PORT = 7890;
|
|
28
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
29
|
+
export const DEFAULT_MAX_EVENTS = 200;
|
|
30
|
+
export const DEFAULT_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
export const DEFAULT_PRUNE_INTERVAL_MS = 60 * 1000;
|
|
32
|
+
|
|
33
|
+
export function loadConfig(): ServerConfig {
|
|
34
|
+
const port = parseInt(process.env.CREWLOOP_DASHBOARD_PORT || String(DEFAULT_PORT), 10);
|
|
35
|
+
const host = process.env.CREWLOOP_DASHBOARD_HOST || DEFAULT_HOST;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
port,
|
|
39
|
+
host,
|
|
40
|
+
packageRoot: resolvePackageRoot(),
|
|
41
|
+
maxEventsPerSession: DEFAULT_MAX_EVENTS,
|
|
42
|
+
sessionMaxAgeMs: DEFAULT_SESSION_MAX_AGE_MS,
|
|
43
|
+
pruneIntervalMs: DEFAULT_PRUNE_INTERVAL_MS,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolvePackageRoot(): string {
|
|
48
|
+
try {
|
|
49
|
+
const skillsPackageJson = require.resolve('@archznn/crewloop-skills/package.json');
|
|
50
|
+
return path.dirname(skillsPackageJson);
|
|
51
|
+
} catch {
|
|
52
|
+
const cwdNodeModules = path.join(process.cwd(), 'node_modules', '@archznn', 'crewloop-skills');
|
|
53
|
+
if (fs.existsSync(path.join(cwdNodeModules, 'package.json'))) {
|
|
54
|
+
return cwdNodeModules;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const monorepoRoot = path.resolve(__dirname, '..', '..', '..');
|
|
59
|
+
if (fs.existsSync(path.join(monorepoRoot, 'skills', 'orchestrator', 'SKILL.md'))) {
|
|
60
|
+
return monorepoRoot;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(
|
|
64
|
+
'Could not find CrewLoop skills package. Install @archznn/crewloop-skills or run from the CrewLoop repository.'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { sanitize, sanitizeEventBoundary } from './sanitize';
|
|
4
|
+
|
|
5
|
+
describe('sanitize', () => {
|
|
6
|
+
it('extracts safe path details', () => {
|
|
7
|
+
const result = sanitize(
|
|
8
|
+
{
|
|
9
|
+
tool_name: 'Read',
|
|
10
|
+
tool_input: { path: 'README.md' },
|
|
11
|
+
},
|
|
12
|
+
'pre'
|
|
13
|
+
);
|
|
14
|
+
assert.equal(result.detail, 'README.md');
|
|
15
|
+
assert.equal(result.status, 'running');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('extracts skill name from Skill tool input', () => {
|
|
19
|
+
const result = sanitize(
|
|
20
|
+
{
|
|
21
|
+
tool_name: 'Skill',
|
|
22
|
+
tool_input: { skill: 'architect' },
|
|
23
|
+
},
|
|
24
|
+
'pre'
|
|
25
|
+
);
|
|
26
|
+
assert.equal(result.detail, 'architect');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('strips dangerous command input', () => {
|
|
30
|
+
const result = sanitize(
|
|
31
|
+
{
|
|
32
|
+
tool_name: 'Bash',
|
|
33
|
+
tool_input: { command: 'rm -rf /' },
|
|
34
|
+
},
|
|
35
|
+
'pre'
|
|
36
|
+
);
|
|
37
|
+
assert.equal(result.detail, undefined);
|
|
38
|
+
assert.equal(result.status, 'running');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('strips content/text input', () => {
|
|
42
|
+
const result = sanitize(
|
|
43
|
+
{
|
|
44
|
+
tool_name: 'Write',
|
|
45
|
+
tool_input: { path: 'secret.env', content: 'API_KEY=123' },
|
|
46
|
+
},
|
|
47
|
+
'pre'
|
|
48
|
+
);
|
|
49
|
+
assert.equal(result.detail, 'secret.env');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('marks success on post event', () => {
|
|
53
|
+
const result = sanitize(
|
|
54
|
+
{
|
|
55
|
+
tool_name: 'Read',
|
|
56
|
+
tool_input: { path: 'README.md' },
|
|
57
|
+
tool_response: { success: true, duration_ms: 12 },
|
|
58
|
+
},
|
|
59
|
+
'post'
|
|
60
|
+
);
|
|
61
|
+
assert.equal(result.status, 'success');
|
|
62
|
+
assert.equal(result.duration_ms, 12);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('marks error on post event', () => {
|
|
66
|
+
const result = sanitize(
|
|
67
|
+
{
|
|
68
|
+
tool_name: 'Bash',
|
|
69
|
+
tool_response: { success: false, durationMs: 45 },
|
|
70
|
+
},
|
|
71
|
+
'post'
|
|
72
|
+
);
|
|
73
|
+
assert.equal(result.status, 'error');
|
|
74
|
+
assert.equal(result.duration_ms, 45);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('extracts hostname from safe url input', () => {
|
|
78
|
+
const result = sanitize(
|
|
79
|
+
{
|
|
80
|
+
tool_name: 'FetchURL',
|
|
81
|
+
tool_input: { url: 'https://example.com/path' },
|
|
82
|
+
},
|
|
83
|
+
'pre'
|
|
84
|
+
);
|
|
85
|
+
assert.equal(result.detail, 'example.com');
|
|
86
|
+
assert.equal(result.status, 'running');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects events with dangerous keys at boundary', () => {
|
|
90
|
+
assert.equal(sanitizeEventBoundary({ command: 'ls' }), false);
|
|
91
|
+
assert.equal(sanitizeEventBoundary({ token: 'abc' }), false);
|
|
92
|
+
assert.equal(sanitizeEventBoundary({ path: 'README.md' }), true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SAFE_TOOL_INPUT_KEYS, DANGEROUS_TOOL_INPUT_KEYS } from '../config';
|
|
2
|
+
import type { EventStatus } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface SanitizeInput {
|
|
5
|
+
tool_name: string;
|
|
6
|
+
tool_input?: Record<string, unknown>;
|
|
7
|
+
tool_response?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SafeDetail {
|
|
11
|
+
detail?: string;
|
|
12
|
+
status?: EventStatus;
|
|
13
|
+
duration_ms?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractSafeDetail(input: Record<string, unknown>): string | undefined {
|
|
17
|
+
for (const [key, value] of Object.entries(input)) {
|
|
18
|
+
const lower = key.toLowerCase();
|
|
19
|
+
if (!SAFE_TOOL_INPUT_KEYS.has(lower)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
if (lower === 'url') {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(value);
|
|
26
|
+
return url.hostname;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sanitize(input: SanitizeInput, event: 'pre' | 'post'): SafeDetail {
|
|
38
|
+
const result: SafeDetail = {};
|
|
39
|
+
|
|
40
|
+
if (input.tool_input) {
|
|
41
|
+
const detail = extractSafeDetail(input.tool_input);
|
|
42
|
+
if (detail) {
|
|
43
|
+
result.detail = detail;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (event === 'post' && input.tool_response && typeof input.tool_response === 'object') {
|
|
48
|
+
const response = input.tool_response as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
if (typeof response.duration_ms === 'number') {
|
|
51
|
+
result.duration_ms = response.duration_ms;
|
|
52
|
+
} else if (typeof response.durationMs === 'number') {
|
|
53
|
+
result.duration_ms = response.durationMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (response.is_error === true || response.success === false || response.error) {
|
|
57
|
+
result.status = 'error';
|
|
58
|
+
} else if (response.success === true || response.is_error === false) {
|
|
59
|
+
result.status = 'success';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (event === 'pre') {
|
|
64
|
+
result.status = 'running';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sanitizeEventBoundary(payload: Record<string, unknown>): boolean {
|
|
71
|
+
const keys = Object.keys(payload);
|
|
72
|
+
for (const key of keys) {
|
|
73
|
+
if (DANGEROUS_TOOL_INPUT_KEYS.has(key.toLowerCase())) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { loadConfig } from './config';
|
|
2
|
+
import { createDashboardServer } from './server';
|
|
3
|
+
|
|
4
|
+
async function main(): Promise<void> {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
const server = createDashboardServer(config);
|
|
7
|
+
|
|
8
|
+
process.on('SIGINT', async () => {
|
|
9
|
+
await server.stop();
|
|
10
|
+
process.exit(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
process.on('SIGTERM', async () => {
|
|
14
|
+
await server.stop();
|
|
15
|
+
process.exit(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await server.start();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
main().catch((err) => {
|
|
22
|
+
console.error('Failed to start dashboard:', err);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { presentSession, presentState, createSnapshotMessage, createUpdateMessage } from './presenter';
|
|
4
|
+
import type { Session, ClientUpdateMessage } from './types';
|
|
5
|
+
|
|
6
|
+
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
7
|
+
return {
|
|
8
|
+
id: 'sess-1',
|
|
9
|
+
source: 'kimi',
|
|
10
|
+
events: [],
|
|
11
|
+
tool_counts: { Read: 2 },
|
|
12
|
+
started_at: 1000,
|
|
13
|
+
last_event_at: 2000,
|
|
14
|
+
active_skill: 'architect',
|
|
15
|
+
active_confidence: 'explicit',
|
|
16
|
+
status: 'running',
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('presenter', () => {
|
|
22
|
+
it('converts session to client shape', () => {
|
|
23
|
+
const session = makeSession();
|
|
24
|
+
const client = presentSession(session);
|
|
25
|
+
|
|
26
|
+
assert.equal(client.id, 'sess-1');
|
|
27
|
+
assert.equal(client.source, 'kimi');
|
|
28
|
+
assert.equal(client.skill, 'architect');
|
|
29
|
+
assert.deepEqual(client.activeSkill, { name: 'architect', confidence: 'explicit' });
|
|
30
|
+
assert.equal(client.status, 'running');
|
|
31
|
+
assert.equal(client.startTime, 1000);
|
|
32
|
+
assert.equal(client.lastActivity, 2000);
|
|
33
|
+
assert.deepEqual(client.toolCounts, { Read: 2 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('omits active skill when not set', () => {
|
|
37
|
+
const client = presentSession(makeSession({ active_skill: undefined }));
|
|
38
|
+
assert.equal(client.activeSkill, undefined);
|
|
39
|
+
assert.equal(client.skill, undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('presents state as client sessions', () => {
|
|
43
|
+
const state = {
|
|
44
|
+
sessions: {
|
|
45
|
+
'sess-1': makeSession(),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const sessions = presentState(state);
|
|
49
|
+
assert.equal(sessions.length, 1);
|
|
50
|
+
assert.equal(sessions[0].id, 'sess-1');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('creates snapshot message', () => {
|
|
54
|
+
const message = createSnapshotMessage([makeSession()]);
|
|
55
|
+
assert.equal(message.type, 'snapshot');
|
|
56
|
+
assert.equal(message.sessions.length, 1);
|
|
57
|
+
assert.equal(message.sessions[0].id, 'sess-1');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('creates update message with isActive flag', () => {
|
|
61
|
+
const message = createUpdateMessage(makeSession(), 'sess-1') as ClientUpdateMessage;
|
|
62
|
+
assert.equal(message.type, 'update');
|
|
63
|
+
assert.equal(message.session.id, 'sess-1');
|
|
64
|
+
assert.equal(message.isActive, true);
|
|
65
|
+
|
|
66
|
+
const inactive = createUpdateMessage(makeSession(), 'sess-2') as ClientUpdateMessage;
|
|
67
|
+
assert.equal(inactive.isActive, false);
|
|
68
|
+
});
|
|
69
|
+
});
|