code-workspace-zhuiyi 0.1.0-beta.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/.env.example +3 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/README.zh-CN.md +168 -0
- package/artifacts/manifest.json +183 -0
- package/artifacts/templates/USER_GUIDE.template.md +1 -0
- package/artifacts/templates/agents/WORKSPACE_GUARD.md.template +73 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/SKILL.md +88 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/evals/evals.json +45 -0
- package/artifacts/templates/claude/commands/code-workspace/add-projects.md +23 -0
- package/artifacts/templates/codex/hooks.json +86 -0
- package/artifacts/templates/codex/skills/code-workspace-add-projects/SKILL.md +28 -0
- package/artifacts/templates/user-guide/en-US.md +97 -0
- package/artifacts/templates/user-guide/zh-CN.md +97 -0
- package/assets/i18n-icon.svg +1 -0
- package/assets/logo-vector.svg +30 -0
- package/assets/request_tip.mp3 +0 -0
- package/assets/session_finish.mp3 +0 -0
- package/bin/code-workspace.js +11 -0
- package/docs/extension-architecture.zh-CN.md +429 -0
- package/docs/extensions.md +64 -0
- package/docs/extensions.zh-CN.md +64 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/claude/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/codex/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/init.js +54 -0
- package/extensions/openspec-workspace/1.0.0/manifest.json +27 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/claude/server.json +14 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/codex/config.toml +11 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/init.js +53 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/lib/archive.js +196 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/manifest.json +38 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/release.json +15 -0
- package/package.json +58 -0
- package/schemas/extension-init-context-v1.json +39 -0
- package/schemas/extension-init-result-v1.json +42 -0
- package/schemas/extension-manifest-v1.json +55 -0
- package/schemas/extension-manifest-v2.json +84 -0
- package/schemas/extension-manifest-v3.json +84 -0
- package/spec/extension/v1/specification.en-US.md +171 -0
- package/spec/extension/v1/specification.zh-CN.md +170 -0
- package/src/cli/commands/completion.js +203 -0
- package/src/cli/commands/extension.js +193 -0
- package/src/cli/commands/help.js +36 -0
- package/src/cli/commands/init.js +200 -0
- package/src/cli/commands/monitor.js +62 -0
- package/src/cli/commands/permissions.js +33 -0
- package/src/cli/commands/project-branch-update.js +107 -0
- package/src/cli/commands/project-branch.js +433 -0
- package/src/cli/commands/project.js +259 -0
- package/src/cli/commands/update.js +146 -0
- package/src/cli/commands/workspace.js +24 -0
- package/src/cli/confirmation.js +22 -0
- package/src/cli/parser.js +108 -0
- package/src/cli/registry.js +108 -0
- package/src/cli/renderer.js +20 -0
- package/src/cli/result.js +118 -0
- package/src/cli.js +76 -0
- package/src/core/assets.js +83 -0
- package/src/core/config.js +378 -0
- package/src/core/diagnostics.js +20 -0
- package/src/core/directory-digest.js +34 -0
- package/src/core/doctor.js +115 -0
- package/src/core/errors.js +10 -0
- package/src/core/extension-artifacts-legacy.js +180 -0
- package/src/core/extension-artifacts.js +310 -0
- package/src/core/extensions.js +1216 -0
- package/src/core/fs.js +32 -0
- package/src/core/init-lock-holder.js +30 -0
- package/src/core/init-lock.js +159 -0
- package/src/core/init.js +160 -0
- package/src/core/initializer.js +233 -0
- package/src/core/language.js +102 -0
- package/src/core/managed-files.js +334 -0
- package/src/core/migration.js +68 -0
- package/src/core/permissions/claude.js +92 -0
- package/src/core/permissions/codex.js +126 -0
- package/src/core/permissions/common.js +44 -0
- package/src/core/permissions/index.js +163 -0
- package/src/core/project-branch-update.js +424 -0
- package/src/core/project-configuration.js +55 -0
- package/src/core/project.js +674 -0
- package/src/core/tools.js +34 -0
- package/src/core/transaction.js +132 -0
- package/src/core/validation.js +186 -0
- package/src/i18n/index.js +11 -0
- package/src/i18n/interpolate.js +7 -0
- package/src/i18n/locales/en-US.js +10 -0
- package/src/i18n/locales/zh-CN.js +11 -0
- package/src/i18n/registry.js +42 -0
- package/src/index.js +25 -0
- package/src/init/plan.js +12 -0
- package/src/init/ui.js +61 -0
- package/src/init/wizard.js +97 -0
- package/src/monitor/i18n/index.js +33 -0
- package/src/monitor/i18n/locales/en-US.js +85 -0
- package/src/monitor/i18n/locales/zh-CN.js +79 -0
- package/src/monitor/index.js +344 -0
- package/src/monitor/page.js +118 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
code: "en-US",
|
|
3
|
+
label: "English",
|
|
4
|
+
default: true,
|
|
5
|
+
messages: {
|
|
6
|
+
title: "Code Workspace Monitor",
|
|
7
|
+
eyebrow: "Local runtime center",
|
|
8
|
+
language: "Language",
|
|
9
|
+
summary: "Monitor summary",
|
|
10
|
+
workspaces: "Workspaces",
|
|
11
|
+
sessions: "Sessions",
|
|
12
|
+
turns: "Turns",
|
|
13
|
+
active: "Active sessions",
|
|
14
|
+
execution: "Execution",
|
|
15
|
+
signals: "Live signals",
|
|
16
|
+
waitingData: "Waiting for data",
|
|
17
|
+
nodes: "{count} nodes",
|
|
18
|
+
pending: " · {count} to review",
|
|
19
|
+
events: "{count} events",
|
|
20
|
+
updated: "Updated {value}",
|
|
21
|
+
never: "never",
|
|
22
|
+
justNow: "just now",
|
|
23
|
+
seconds: "{count} seconds ago",
|
|
24
|
+
minutes: "{count} minutes ago",
|
|
25
|
+
hours: "{count} hours ago",
|
|
26
|
+
workspaceMeta: "{active} active / {sessions} total sessions · {signals} signals · {updated}",
|
|
27
|
+
approval: "Approval {count}",
|
|
28
|
+
complete: "Complete {count}",
|
|
29
|
+
actions: "actions",
|
|
30
|
+
removeWorkspace: "Remove workspace",
|
|
31
|
+
removeSession: "Delete session",
|
|
32
|
+
emptyWorkspaces: "No workspace signals yet",
|
|
33
|
+
waitingAgent: "Waiting for Agent signals",
|
|
34
|
+
waitingAgentDetail:
|
|
35
|
+
"Enable monitoring in a workspace and trust its Codex hooks.",
|
|
36
|
+
session: "Session",
|
|
37
|
+
lastSignal: "Last signal {value}",
|
|
38
|
+
inactiveReason: "No signal received for more than 10 minutes",
|
|
39
|
+
turn: "Turn",
|
|
40
|
+
tool: "Tool",
|
|
41
|
+
signalsCount: "{count} signals",
|
|
42
|
+
noSessions: "No session records.",
|
|
43
|
+
noTurns: "No turn details yet.",
|
|
44
|
+
noEvents: "No events",
|
|
45
|
+
soundUnavailable: "Notification sound unavailable",
|
|
46
|
+
soundWarning: "Unable to enable monitor sound:",
|
|
47
|
+
soundLoadingTitle: "Loading notification sound",
|
|
48
|
+
soundFirstUse:
|
|
49
|
+
"Sound is on; interact with the page once to enable playback",
|
|
50
|
+
soundDisable: "Disable notification sound",
|
|
51
|
+
soundEnable: "Enable notification sound",
|
|
52
|
+
soundLoading: "Loading sound",
|
|
53
|
+
soundOn: "Sound on",
|
|
54
|
+
soundOff: "Sound off",
|
|
55
|
+
removeConfirm:
|
|
56
|
+
"Remove “{name}” from the monitor? It will return automatically when a new signal arrives.",
|
|
57
|
+
removeSessionConfirm:
|
|
58
|
+
"Delete session “{id}” and its related signals?",
|
|
59
|
+
removeActiveSessionConfirm:
|
|
60
|
+
"Session “{id}” is still active. Delete it? It will return when a new signal arrives.",
|
|
61
|
+
live: "Live",
|
|
62
|
+
connecting: "Connecting",
|
|
63
|
+
reconnecting: "Reconnecting",
|
|
64
|
+
status: {
|
|
65
|
+
IDLE: "Idle",
|
|
66
|
+
RUNNING: "Running",
|
|
67
|
+
WAITING_APPROVAL: "Waiting approval",
|
|
68
|
+
RECENT_COMPLETE: "Just completed",
|
|
69
|
+
STOPPED: "Stopped",
|
|
70
|
+
ACTIVE: "Active",
|
|
71
|
+
INACTIVE: "Inactive",
|
|
72
|
+
ENDED: "Ended",
|
|
73
|
+
SESSION_ENDED: "Session ended",
|
|
74
|
+
},
|
|
75
|
+
event: {
|
|
76
|
+
"turn.started": "Turn started",
|
|
77
|
+
"approval.requested": "Approval requested",
|
|
78
|
+
"tool.completed": "Tool completed",
|
|
79
|
+
"subagent.started": "Subagent started",
|
|
80
|
+
"subagent.stopped": "Subagent stopped",
|
|
81
|
+
"turn.stopped": "Turn completed",
|
|
82
|
+
"session.ended": "Session ended",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
code: "zh-CN",
|
|
3
|
+
label: "中文(简体)",
|
|
4
|
+
messages: {
|
|
5
|
+
title: "Code Workspace 监控",
|
|
6
|
+
eyebrow: "本地运行中心",
|
|
7
|
+
language: "语言",
|
|
8
|
+
summary: "监控汇总",
|
|
9
|
+
workspaces: "工作区",
|
|
10
|
+
sessions: "会话",
|
|
11
|
+
turns: "轮次",
|
|
12
|
+
active: "活跃会话",
|
|
13
|
+
execution: "执行视图",
|
|
14
|
+
signals: "实时信号",
|
|
15
|
+
waitingData: "等待数据",
|
|
16
|
+
nodes: "{count} 个节点",
|
|
17
|
+
pending: " · {count} 个待查看",
|
|
18
|
+
events: "{count} 条事件",
|
|
19
|
+
updated: "更新于 {value}",
|
|
20
|
+
never: "从未",
|
|
21
|
+
justNow: "刚刚",
|
|
22
|
+
seconds: "{count} 秒前",
|
|
23
|
+
minutes: "{count} 分钟前",
|
|
24
|
+
hours: "{count} 小时前",
|
|
25
|
+
workspaceMeta: "活跃 {active} / 总计 {sessions} 个会话 · {signals} 条信号 · {updated}",
|
|
26
|
+
approval: "待授权 {count}",
|
|
27
|
+
complete: "已完成 {count}",
|
|
28
|
+
actions: "操作",
|
|
29
|
+
removeWorkspace: "移除工作区",
|
|
30
|
+
removeSession: "删除会话",
|
|
31
|
+
emptyWorkspaces: "暂无工作区信号",
|
|
32
|
+
waitingAgent: "等待 Agent 信号",
|
|
33
|
+
waitingAgentDetail: "请在工作区中启用监控,并信任其 Codex hooks。",
|
|
34
|
+
session: "会话",
|
|
35
|
+
lastSignal: "最后信号 {value}",
|
|
36
|
+
inactiveReason: "超过 10 分钟未收到信号",
|
|
37
|
+
turn: "轮次",
|
|
38
|
+
tool: "工具",
|
|
39
|
+
signalsCount: "{count} 条信号",
|
|
40
|
+
noSessions: "暂无会话记录。",
|
|
41
|
+
noTurns: "暂无轮次详情。",
|
|
42
|
+
noEvents: "暂无事件",
|
|
43
|
+
soundUnavailable: "提示音不可用",
|
|
44
|
+
soundWarning: "无法启用监控提示音:",
|
|
45
|
+
soundLoadingTitle: "正在加载事件提示音",
|
|
46
|
+
soundFirstUse: "声音默认开启;首次操作页面后即可播放",
|
|
47
|
+
soundDisable: "关闭事件提示音",
|
|
48
|
+
soundEnable: "启用事件提示音",
|
|
49
|
+
soundLoading: "正在加载声音",
|
|
50
|
+
soundOn: "声音已开启",
|
|
51
|
+
soundOff: "声音已关闭",
|
|
52
|
+
removeConfirm: "确定从监控中移除“{name}”吗?收到新信号后它会自动恢复。",
|
|
53
|
+
removeSessionConfirm: "确定删除会话“{id}”及其关联信号吗?",
|
|
54
|
+
removeActiveSessionConfirm: "会话“{id}”仍处于活跃状态。确定删除吗?收到新信号后它会重新出现。",
|
|
55
|
+
live: "实时连接",
|
|
56
|
+
connecting: "连接中",
|
|
57
|
+
reconnecting: "正在重连",
|
|
58
|
+
status: {
|
|
59
|
+
IDLE: "空闲",
|
|
60
|
+
RUNNING: "执行中",
|
|
61
|
+
WAITING_APPROVAL: "等待授权",
|
|
62
|
+
RECENT_COMPLETE: "刚完成",
|
|
63
|
+
STOPPED: "已停止",
|
|
64
|
+
ACTIVE: "活跃",
|
|
65
|
+
INACTIVE: "非活跃",
|
|
66
|
+
ENDED: "已结束",
|
|
67
|
+
SESSION_ENDED: "会话结束",
|
|
68
|
+
},
|
|
69
|
+
event: {
|
|
70
|
+
"turn.started": "轮次开始",
|
|
71
|
+
"approval.requested": "请求授权",
|
|
72
|
+
"tool.completed": "工具完成",
|
|
73
|
+
"subagent.started": "子 Agent 启动",
|
|
74
|
+
"subagent.stopped": "子 Agent 停止",
|
|
75
|
+
"turn.stopped": "轮次完成",
|
|
76
|
+
"session.ended": "会话结束",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
const http = require("node:http");
|
|
2
|
+
const { randomUUID } = require("node:crypto");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { renderMonitorPage } = require("./page");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MONITOR_HOST = "127.0.0.1";
|
|
8
|
+
const DEFAULT_MONITOR_PORT = 3211;
|
|
9
|
+
const DEFAULT_SESSION_INACTIVITY_MS = 10 * 60 * 1000;
|
|
10
|
+
const MONITOR_ASSETS = new Map([
|
|
11
|
+
["/assets/request_tip.mp3", { file: path.join(__dirname, "..", "..", "assets", "request_tip.mp3"), type: "audio/mpeg" }],
|
|
12
|
+
["/assets/session_finish.mp3", { file: path.join(__dirname, "..", "..", "assets", "session_finish.mp3"), type: "audio/mpeg" }],
|
|
13
|
+
["/assets/logo-vector.svg", { file: path.join(__dirname, "..", "..", "assets", "logo-vector.svg"), type: "image/svg+xml; charset=utf-8" }],
|
|
14
|
+
["/assets/i18n-icon.svg", { file: path.join(__dirname, "..", "..", "assets", "i18n-icon.svg"), type: "image/svg+xml; charset=utf-8" }],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const EVENT_DEFINITIONS = {
|
|
18
|
+
UserPromptSubmit: ["turn.started", "RUNNING"],
|
|
19
|
+
PermissionRequest: ["approval.requested", "WAITING_APPROVAL"],
|
|
20
|
+
PostToolUse: ["tool.completed", "RUNNING"],
|
|
21
|
+
SubagentStart: ["subagent.started", "RUNNING"],
|
|
22
|
+
SubagentStop: ["subagent.stopped", "RUNNING"],
|
|
23
|
+
Stop: ["turn.stopped", "STOPPED"],
|
|
24
|
+
SessionEnd: ["session.ended", "SESSION_ENDED"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function validatePort(value, options = {}) {
|
|
28
|
+
const port = Number(value ?? DEFAULT_MONITOR_PORT);
|
|
29
|
+
const minimum = options.allowZero ? 0 : 1;
|
|
30
|
+
if (!Number.isInteger(port) || port < minimum || port > 65535) throw new Error("Monitor port must be between 1 and 65535");
|
|
31
|
+
return port;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeHookEvent(input, config) {
|
|
35
|
+
const definition = EVENT_DEFINITIONS[input?.hook_event_name];
|
|
36
|
+
if (!definition || typeof input.session_id !== "string") return null;
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
eventId: randomUUID(),
|
|
40
|
+
source: "codex",
|
|
41
|
+
eventType: definition[0],
|
|
42
|
+
status: definition[1],
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
workspace: { uuid: config.workspace.uuid, name: config.workspace.name },
|
|
45
|
+
session: { id: input.session_id },
|
|
46
|
+
turn: input.turn_id ? { id: input.turn_id } : null,
|
|
47
|
+
context: {
|
|
48
|
+
cwd: input.cwd || null,
|
|
49
|
+
model: input.model || null,
|
|
50
|
+
permissionMode: input.permission_mode || null,
|
|
51
|
+
},
|
|
52
|
+
tool: input.tool_name ? { name: input.tool_name, useId: input.tool_use_id || null } : null,
|
|
53
|
+
subagent: input.agent_id ? { id: input.agent_id, type: input.agent_type || null } : null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validEvent(event) {
|
|
58
|
+
return Boolean(
|
|
59
|
+
event?.schemaVersion === 1 &&
|
|
60
|
+
typeof event.eventId === "string" &&
|
|
61
|
+
typeof event.eventType === "string" &&
|
|
62
|
+
typeof event.status === "string" &&
|
|
63
|
+
typeof event.workspace?.uuid === "string" &&
|
|
64
|
+
typeof event.workspace?.name === "string" &&
|
|
65
|
+
typeof event.session?.id === "string"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMonitorStore(options = {}) {
|
|
70
|
+
const maxEvents = Number(options.maxEvents || 1000);
|
|
71
|
+
if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new Error("maxEvents must be a positive integer");
|
|
72
|
+
const sessionInactivityMs = Number(options.sessionInactivityMs ?? DEFAULT_SESSION_INACTIVITY_MS);
|
|
73
|
+
if (!Number.isInteger(sessionInactivityMs) || sessionInactivityMs < 1) {
|
|
74
|
+
throw new Error("sessionInactivityMs must be a positive integer");
|
|
75
|
+
}
|
|
76
|
+
const clock = typeof options.now === "function" ? options.now : () => new Date();
|
|
77
|
+
const events = [];
|
|
78
|
+
const workspaces = new Map();
|
|
79
|
+
const subscribers = new Set();
|
|
80
|
+
|
|
81
|
+
function nowIso() {
|
|
82
|
+
const value = clock();
|
|
83
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
84
|
+
if (Number.isNaN(date.getTime())) throw new Error("monitor clock returned an invalid date");
|
|
85
|
+
return date.toISOString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function publish(message) {
|
|
89
|
+
const payload = `event: update\ndata: ${JSON.stringify(message)}\n\n`;
|
|
90
|
+
for (const response of subscribers) response.write(payload);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function accept(event) {
|
|
94
|
+
if (!validEvent(event)) throw new Error("invalid event payload");
|
|
95
|
+
if (events.some((current) => current.eventId === event.eventId)) return { duplicate: true };
|
|
96
|
+
const receivedAt = nowIso();
|
|
97
|
+
const storedEvent = { ...event, timestamp: event.timestamp || receivedAt };
|
|
98
|
+
const previous = workspaces.get(event.workspace.uuid) || {
|
|
99
|
+
uuid: event.workspace.uuid,
|
|
100
|
+
name: event.workspace.name,
|
|
101
|
+
sessions: {},
|
|
102
|
+
eventCount: 0,
|
|
103
|
+
firstSeenAt: receivedAt,
|
|
104
|
+
};
|
|
105
|
+
const session = previous.sessions[event.session.id] || {
|
|
106
|
+
id: event.session.id,
|
|
107
|
+
turns: {},
|
|
108
|
+
status: "ACTIVE",
|
|
109
|
+
firstSeenAt: receivedAt,
|
|
110
|
+
};
|
|
111
|
+
const turnId = event.turn?.id;
|
|
112
|
+
if (turnId) {
|
|
113
|
+
const turn = session.turns[turnId] || { id: turnId, eventCount: 0, startedAt: receivedAt };
|
|
114
|
+
turn.status = event.status;
|
|
115
|
+
turn.eventCount += 1;
|
|
116
|
+
turn.updatedAt = receivedAt;
|
|
117
|
+
turn.tool = event.tool;
|
|
118
|
+
turn.subagent = event.subagent;
|
|
119
|
+
session.turns[turnId] = turn;
|
|
120
|
+
}
|
|
121
|
+
if (event.status === "SESSION_ENDED") session.status = "ENDED";
|
|
122
|
+
session.eventCount = (session.eventCount || 0) + 1;
|
|
123
|
+
session.lastSignalAt = receivedAt;
|
|
124
|
+
session.updatedAt = receivedAt;
|
|
125
|
+
previous.name = event.workspace.name;
|
|
126
|
+
previous.sessions[event.session.id] = session;
|
|
127
|
+
previous.eventCount += 1;
|
|
128
|
+
previous.updatedAt = receivedAt;
|
|
129
|
+
workspaces.set(previous.uuid, previous);
|
|
130
|
+
events.unshift(storedEvent);
|
|
131
|
+
if (events.length > maxEvents) events.length = maxEvents;
|
|
132
|
+
publish({ event: storedEvent, workspace: previous });
|
|
133
|
+
return { duplicate: false, workspaceUuid: previous.uuid };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function removeWorkspace(uuid) {
|
|
137
|
+
if (!workspaces.delete(uuid)) return { removed: false, workspaceUuid: uuid };
|
|
138
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
139
|
+
if (events[index].workspace.uuid === uuid) events.splice(index, 1);
|
|
140
|
+
}
|
|
141
|
+
publish({ workspaceRemoved: uuid });
|
|
142
|
+
return { removed: true, workspaceUuid: uuid };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function removeSession(workspaceUuid, sessionId) {
|
|
146
|
+
const workspace = workspaces.get(workspaceUuid);
|
|
147
|
+
if (!workspace || !workspace.sessions[sessionId]) {
|
|
148
|
+
return { removed: false, workspaceUuid, sessionId };
|
|
149
|
+
}
|
|
150
|
+
const session = workspace.sessions[sessionId];
|
|
151
|
+
delete workspace.sessions[sessionId];
|
|
152
|
+
let removedEvents = 0;
|
|
153
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
154
|
+
if (events[index].workspace.uuid === workspaceUuid && events[index].session.id === sessionId) {
|
|
155
|
+
events.splice(index, 1);
|
|
156
|
+
removedEvents += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
workspace.eventCount = Math.max(0, workspace.eventCount - (session.eventCount || removedEvents));
|
|
160
|
+
const remainingSessions = Object.values(workspace.sessions);
|
|
161
|
+
workspace.updatedAt = remainingSessions.reduce(
|
|
162
|
+
(latest, session) => latest > session.updatedAt ? latest : session.updatedAt,
|
|
163
|
+
workspace.firstSeenAt
|
|
164
|
+
);
|
|
165
|
+
publish({ sessionRemoved: { workspaceUuid, sessionId } });
|
|
166
|
+
return { removed: true, workspaceUuid, sessionId, removedEvents };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function effectiveSessionStatus(session, serverTime) {
|
|
170
|
+
if (session.status === "ENDED") return "ENDED";
|
|
171
|
+
const lastSignalAt = session.lastSignalAt || session.updatedAt || session.firstSeenAt;
|
|
172
|
+
const elapsed = new Date(serverTime).getTime() - new Date(lastSignalAt).getTime();
|
|
173
|
+
return Number.isFinite(elapsed) && elapsed >= sessionInactivityMs ? "INACTIVE" : "ACTIVE";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function snapshot() {
|
|
177
|
+
const serverTime = nowIso();
|
|
178
|
+
const summary = { workspaces: workspaces.size, sessions: 0, activeSessions: 0, turns: 0 };
|
|
179
|
+
const projectedWorkspaces = [...workspaces.values()].map((workspace) => {
|
|
180
|
+
const sessions = {};
|
|
181
|
+
let activeSessionCount = 0;
|
|
182
|
+
for (const session of Object.values(workspace.sessions)) {
|
|
183
|
+
const status = effectiveSessionStatus(session, serverTime);
|
|
184
|
+
if (status === "ACTIVE") activeSessionCount += 1;
|
|
185
|
+
summary.sessions += 1;
|
|
186
|
+
summary.turns += Object.keys(session.turns).length;
|
|
187
|
+
sessions[session.id] = { ...session, status };
|
|
188
|
+
}
|
|
189
|
+
summary.activeSessions += activeSessionCount;
|
|
190
|
+
return {
|
|
191
|
+
...workspace,
|
|
192
|
+
sessions,
|
|
193
|
+
sessionCount: Object.keys(sessions).length,
|
|
194
|
+
activeSessionCount,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
workspaces: projectedWorkspaces.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
199
|
+
events: events.slice(),
|
|
200
|
+
serverTime,
|
|
201
|
+
summary,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { accept, events, removeSession, removeWorkspace, snapshot, subscribers, workspaces };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readJson(request, limit = 256_000) {
|
|
209
|
+
const chunks = [];
|
|
210
|
+
let size = 0;
|
|
211
|
+
for await (const chunk of request) {
|
|
212
|
+
size += chunk.length;
|
|
213
|
+
if (size > limit) throw new Error("request body is too large");
|
|
214
|
+
chunks.push(chunk);
|
|
215
|
+
}
|
|
216
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sendJson(response, status, body) {
|
|
220
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
221
|
+
response.end(JSON.stringify(body));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sendHtml(response, body) {
|
|
225
|
+
response.writeHead(200, {
|
|
226
|
+
"content-type": "text/html; charset=utf-8",
|
|
227
|
+
"cache-control": "no-store",
|
|
228
|
+
"content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; media-src 'self'",
|
|
229
|
+
"x-content-type-options": "nosniff",
|
|
230
|
+
});
|
|
231
|
+
response.end(body);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sendAsset(response, asset) {
|
|
235
|
+
const body = fs.readFileSync(asset.file);
|
|
236
|
+
response.writeHead(200, {
|
|
237
|
+
"content-type": asset.type,
|
|
238
|
+
"content-length": body.length,
|
|
239
|
+
"cache-control": "public, max-age=3600",
|
|
240
|
+
"x-content-type-options": "nosniff",
|
|
241
|
+
});
|
|
242
|
+
response.end(body);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createMonitorServer(options = {}) {
|
|
246
|
+
const host = options.host || DEFAULT_MONITOR_HOST;
|
|
247
|
+
const port = validatePort(options.port, { allowZero: options.allowZero === true });
|
|
248
|
+
const store = options.store || createMonitorStore(options);
|
|
249
|
+
const server = http.createServer(async (request, response) => {
|
|
250
|
+
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
251
|
+
try {
|
|
252
|
+
if (request.method === "GET" && url.pathname === "/") return sendHtml(response, renderMonitorPage());
|
|
253
|
+
if (request.method === "GET" && MONITOR_ASSETS.has(url.pathname)) return sendAsset(response, MONITOR_ASSETS.get(url.pathname));
|
|
254
|
+
if (request.method === "GET" && url.pathname === "/api/v1/health") return sendJson(response, 200, { ok: true });
|
|
255
|
+
if (request.method === "GET" && url.pathname === "/api/v1/snapshot") return sendJson(response, 200, store.snapshot());
|
|
256
|
+
if (request.method === "GET" && url.pathname === "/api/v1/workspaces") return sendJson(response, 200, { workspaces: store.snapshot().workspaces });
|
|
257
|
+
if (request.method === "GET" && url.pathname === "/api/v1/events") return sendJson(response, 200, { events: store.snapshot().events });
|
|
258
|
+
const sessionRoute = url.pathname.match(/^\/api\/v1\/workspaces\/([^/]+)\/sessions\/([^/]+)$/);
|
|
259
|
+
if (request.method === "DELETE" && sessionRoute) {
|
|
260
|
+
const uuid = decodeURIComponent(sessionRoute[1]);
|
|
261
|
+
const sessionId = decodeURIComponent(sessionRoute[2]);
|
|
262
|
+
if (!uuid || !sessionId || uuid.includes("/") || sessionId.includes("/")) {
|
|
263
|
+
return sendJson(response, 404, { error: "not found" });
|
|
264
|
+
}
|
|
265
|
+
const result = store.removeSession(uuid, sessionId);
|
|
266
|
+
return sendJson(response, result.removed ? 200 : 404, result.removed ? result : { error: "session not found" });
|
|
267
|
+
}
|
|
268
|
+
const workspaceRoute = url.pathname.match(/^\/api\/v1\/workspaces\/([^/]+)$/);
|
|
269
|
+
if (request.method === "DELETE" && workspaceRoute) {
|
|
270
|
+
const uuid = decodeURIComponent(workspaceRoute[1]);
|
|
271
|
+
if (!uuid || uuid.includes("/")) return sendJson(response, 404, { error: "not found" });
|
|
272
|
+
const result = store.removeWorkspace(uuid);
|
|
273
|
+
return sendJson(response, result.removed ? 200 : 404, result.removed ? result : { error: "workspace not found" });
|
|
274
|
+
}
|
|
275
|
+
if (request.method === "GET" && url.pathname === "/api/v1/stream") {
|
|
276
|
+
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
277
|
+
response.write(": connected\n\n");
|
|
278
|
+
store.subscribers.add(response);
|
|
279
|
+
request.on("close", () => store.subscribers.delete(response));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (request.method === "POST" && url.pathname === "/api/v1/events") {
|
|
283
|
+
const result = store.accept(await readJson(request));
|
|
284
|
+
return sendJson(response, result.duplicate ? 200 : 202, { accepted: true, ...result });
|
|
285
|
+
}
|
|
286
|
+
return sendJson(response, 404, { error: "not found" });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return sendJson(response, 400, { error: error.message });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return { host, port, server, store };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function startMonitor(options = {}) {
|
|
295
|
+
const monitor = createMonitorServer(options);
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const onError = (error) => {
|
|
298
|
+
monitor.server.off("listening", onListening);
|
|
299
|
+
reject(error);
|
|
300
|
+
};
|
|
301
|
+
const onListening = () => {
|
|
302
|
+
monitor.server.off("error", onError);
|
|
303
|
+
resolve(monitor);
|
|
304
|
+
};
|
|
305
|
+
monitor.server.once("error", onError);
|
|
306
|
+
monitor.server.once("listening", onListening);
|
|
307
|
+
monitor.server.listen(monitor.port, monitor.host);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function reportHookEvent(input, config, options = {}) {
|
|
312
|
+
if (!config.monitor?.enable || !config.workspace) return { action: "skip", reason: "monitor disabled" };
|
|
313
|
+
const event = normalizeHookEvent(input, config);
|
|
314
|
+
if (!event) return { action: "skip", reason: "unsupported event" };
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 300);
|
|
317
|
+
try {
|
|
318
|
+
const response = await (options.fetch || fetch)(`${config.monitor.url}/api/v1/events`, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: { "content-type": "application/json" },
|
|
321
|
+
body: JSON.stringify(event),
|
|
322
|
+
signal: controller.signal,
|
|
323
|
+
});
|
|
324
|
+
return { action: response.ok ? "report" : "skip", status: response.status, event };
|
|
325
|
+
} catch {
|
|
326
|
+
return { action: "skip", reason: "monitor unavailable", event };
|
|
327
|
+
} finally {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
DEFAULT_MONITOR_HOST,
|
|
334
|
+
DEFAULT_MONITOR_PORT,
|
|
335
|
+
DEFAULT_SESSION_INACTIVITY_MS,
|
|
336
|
+
EVENT_DEFINITIONS,
|
|
337
|
+
createMonitorServer,
|
|
338
|
+
createMonitorStore,
|
|
339
|
+
normalizeHookEvent,
|
|
340
|
+
reportHookEvent,
|
|
341
|
+
startMonitor,
|
|
342
|
+
validatePort,
|
|
343
|
+
validEvent,
|
|
344
|
+
};
|