feishu-codex-console 1.0.0-beta.6
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 +101 -0
- package/.feishu-codex-policy.example.json +11 -0
- package/.feishu-codex-runbooks.example.json +36 -0
- package/CHANGELOG.md +129 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +81 -0
- package/README.md +398 -0
- package/ROADMAP.md +40 -0
- package/SECURITY.md +53 -0
- package/dist/account-quota-card.js +233 -0
- package/dist/account-quota.js +125 -0
- package/dist/app-server-client.js +281 -0
- package/dist/card-session.js +166 -0
- package/dist/codex-events.js +1 -0
- package/dist/codex-runner.js +875 -0
- package/dist/config.js +198 -0
- package/dist/confirmation-card.js +135 -0
- package/dist/control-card.js +345 -0
- package/dist/conversation-turn-session.js +209 -0
- package/dist/data-maintenance.js +71 -0
- package/dist/device-card.js +460 -0
- package/dist/device-health.js +94 -0
- package/dist/diagnostics.js +253 -0
- package/dist/doctor.js +250 -0
- package/dist/fallback-card-session.js +37 -0
- package/dist/health-file.js +75 -0
- package/dist/index.js +4330 -0
- package/dist/lark-cli.js +558 -0
- package/dist/lark-retry.js +34 -0
- package/dist/maintenance.js +140 -0
- package/dist/model-capabilities.js +31 -0
- package/dist/onboarding-card.js +312 -0
- package/dist/permission-lease.js +22 -0
- package/dist/policy.js +506 -0
- package/dist/progress.js +267 -0
- package/dist/project-card.js +303 -0
- package/dist/project-overview-card.js +182 -0
- package/dist/project-overview.js +278 -0
- package/dist/project-policy.js +160 -0
- package/dist/project-registry.js +259 -0
- package/dist/project-status.js +45 -0
- package/dist/project-workspace.js +55 -0
- package/dist/quota-card.js +94 -0
- package/dist/recovery-policy.js +26 -0
- package/dist/redaction.js +67 -0
- package/dist/remote-ready.js +112 -0
- package/dist/response-card.js +139 -0
- package/dist/result-card.js +166 -0
- package/dist/review-card.js +452 -0
- package/dist/runbook-card.js +272 -0
- package/dist/runbooks.js +191 -0
- package/dist/runtime-card.js +337 -0
- package/dist/session-card.js +128 -0
- package/dist/session-naming.js +14 -0
- package/dist/smoke.js +28 -0
- package/dist/state-backup.js +302 -0
- package/dist/state-store.js +874 -0
- package/dist/task-card.js +640 -0
- package/dist/task-center-card.js +176 -0
- package/dist/task-failure.js +43 -0
- package/dist/task-intent.js +76 -0
- package/dist/task-queue.js +187 -0
- package/dist/task-reconciliation.js +80 -0
- package/dist/task-review.js +497 -0
- package/dist/team-card.js +275 -0
- package/dist/team-directory.js +54 -0
- package/dist/team-policy.js +93 -0
- package/dist/types.js +1 -0
- package/dist/version.js +9 -0
- package/dist/workspace-session.js +64 -0
- package/docs/ARCHITECTURE.md +54 -0
- package/docs/COMPATIBILITY.md +55 -0
- package/docs/CONFIGURATION.md +88 -0
- package/docs/DEMO.md +45 -0
- package/docs/GOOD_FIRST_ISSUES.md +23 -0
- package/docs/INSTALLATION.md +207 -0
- package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
- package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
- package/docs/RELEASE_CHECKLIST.md +65 -0
- package/docs/TEAM_DEPLOYMENT.md +35 -0
- package/docs/TROUBLESHOOTING.md +130 -0
- package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
- package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
- package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
- package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
- package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
- package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
- package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
- package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
- package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
- package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
- package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
- package/package.json +76 -0
- package/scripts/capability-probe.mjs +113 -0
- package/scripts/cli.mjs +919 -0
- package/scripts/config-file.mjs +137 -0
- package/scripts/discovery-lib.mjs +78 -0
- package/scripts/install-card.mjs +37 -0
- package/scripts/install-detection.mjs +126 -0
- package/scripts/install-state.mjs +107 -0
- package/scripts/launchd.mjs +161 -0
- package/scripts/migrate-legacy.mjs +97 -0
- package/scripts/package-smoke.mjs +163 -0
- package/scripts/release-dist-tag.mjs +7 -0
- package/scripts/runbook-template.mjs +36 -0
- package/scripts/service-health.mjs +110 -0
- package/scripts/service.mjs +24 -0
- package/scripts/setup-lib.mjs +118 -0
- package/scripts/systemd.mjs +96 -0
- package/scripts/upgrade-lib.mjs +99 -0
- package/scripts/verify-release.mjs +37 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
export function renderTeamDashboardCard(snapshot) {
|
|
2
|
+
return {
|
|
3
|
+
schema: "2.0",
|
|
4
|
+
config: {
|
|
5
|
+
update_multi: true,
|
|
6
|
+
width_mode: "default",
|
|
7
|
+
enable_forward: false,
|
|
8
|
+
summary: {
|
|
9
|
+
content: `Codex 团队工作台 · 活跃 ${snapshot.activeTasks} · 成功率 ${successRateLabel(snapshot.successRate)}`,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
header: {
|
|
13
|
+
title: { tag: "plain_text", content: "Codex 团队工作台" },
|
|
14
|
+
subtitle: { tag: "plain_text", content: snapshot.scopeLabel },
|
|
15
|
+
template: snapshot.activeTasks > 0 ? "blue" : "turquoise",
|
|
16
|
+
icon: { tag: "standard_icon", token: "group_colorful" },
|
|
17
|
+
text_tag_list: [
|
|
18
|
+
{
|
|
19
|
+
tag: "text_tag",
|
|
20
|
+
text: { tag: "plain_text", content: snapshot.periodLabel },
|
|
21
|
+
color: "neutral",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
tag: "text_tag",
|
|
25
|
+
text: {
|
|
26
|
+
tag: "plain_text",
|
|
27
|
+
content: snapshot.canAdminister ? "团队视图" : "个人视图",
|
|
28
|
+
},
|
|
29
|
+
color: snapshot.canAdminister ? "violet" : "blue",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
body: {
|
|
34
|
+
direction: "vertical",
|
|
35
|
+
padding: "12px 12px 18px 12px",
|
|
36
|
+
vertical_spacing: "12px",
|
|
37
|
+
elements: [
|
|
38
|
+
...(snapshot.feedback ? [feedbackPanel(snapshot.feedback)] : []),
|
|
39
|
+
metricRow(snapshot),
|
|
40
|
+
usagePanel(snapshot),
|
|
41
|
+
sectionTitle("team_members_title", "成员状态", `${snapshot.members.length} 位可见成员`),
|
|
42
|
+
...(snapshot.members.length > 0
|
|
43
|
+
? snapshot.members.slice(0, 8).map(memberPanel)
|
|
44
|
+
: [emptyPanel("team_members_empty", "暂无成员任务数据")]),
|
|
45
|
+
sectionTitle("team_projects_title", "项目负载", "按最近保留的任务记录统计"),
|
|
46
|
+
...(snapshot.projects.length > 0
|
|
47
|
+
? snapshot.projects.slice(0, 6).map(projectPanel)
|
|
48
|
+
: [emptyPanel("team_projects_empty", "暂无项目任务数据")]),
|
|
49
|
+
actionGrid(),
|
|
50
|
+
{
|
|
51
|
+
tag: "markdown",
|
|
52
|
+
element_id: "team_privacy_note",
|
|
53
|
+
text_size: "notation",
|
|
54
|
+
text_align: "center",
|
|
55
|
+
content: "<font color='grey'>团队面板不展示提示词、结果正文或 open_id;管理操作均写入本地审计。</font>",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function metricRow(snapshot) {
|
|
62
|
+
return {
|
|
63
|
+
tag: "column_set",
|
|
64
|
+
element_id: "team_metrics",
|
|
65
|
+
flex_mode: "none",
|
|
66
|
+
horizontal_spacing: "8px",
|
|
67
|
+
columns: [
|
|
68
|
+
metricColumn("活跃任务", String(snapshot.activeTasks), "blue"),
|
|
69
|
+
metricColumn("排队任务", String(snapshot.queuedTasks), "grey"),
|
|
70
|
+
metricColumn("成功率", successRateLabel(snapshot.successRate), "green"),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function metricColumn(label, value, color) {
|
|
75
|
+
return {
|
|
76
|
+
tag: "column",
|
|
77
|
+
width: "weighted",
|
|
78
|
+
weight: 1,
|
|
79
|
+
background_style: `${color}-50`,
|
|
80
|
+
padding: "10px 6px 10px 6px",
|
|
81
|
+
vertical_spacing: "2px",
|
|
82
|
+
elements: [
|
|
83
|
+
{
|
|
84
|
+
tag: "markdown",
|
|
85
|
+
text_align: "center",
|
|
86
|
+
text_size: "notation",
|
|
87
|
+
content: `<font color='grey'>${safe(label)}</font>`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
tag: "markdown",
|
|
91
|
+
text_align: "center",
|
|
92
|
+
content: `**${safe(value)}**`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function usagePanel(snapshot) {
|
|
98
|
+
return {
|
|
99
|
+
tag: "column_set",
|
|
100
|
+
element_id: "team_usage",
|
|
101
|
+
flex_mode: "none",
|
|
102
|
+
columns: [
|
|
103
|
+
{
|
|
104
|
+
tag: "column",
|
|
105
|
+
width: "weighted",
|
|
106
|
+
weight: 1,
|
|
107
|
+
background_style: "grey-50",
|
|
108
|
+
padding: "10px 12px 10px 12px",
|
|
109
|
+
elements: [
|
|
110
|
+
{
|
|
111
|
+
tag: "markdown",
|
|
112
|
+
element_id: "team_usage_text",
|
|
113
|
+
content: `**资源概览** 已完成 ${formatNumber(snapshot.completedTasks)} 个任务\n` +
|
|
114
|
+
`<font color='grey'>输入 / 输出 tokens</font> ${formatNumber(snapshot.inputTokens)} / ${formatNumber(snapshot.outputTokens)}`,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function memberPanel(member, index) {
|
|
122
|
+
const completed = member.succeeded + member.failed;
|
|
123
|
+
const rate = completed > 0 ? member.succeeded / completed : undefined;
|
|
124
|
+
return {
|
|
125
|
+
tag: "column_set",
|
|
126
|
+
element_id: `team_member_${index}`,
|
|
127
|
+
flex_mode: "none",
|
|
128
|
+
columns: [
|
|
129
|
+
{
|
|
130
|
+
tag: "column",
|
|
131
|
+
width: "weighted",
|
|
132
|
+
weight: 1,
|
|
133
|
+
background_style: member.active > 0 ? "blue-50" : "grey-50",
|
|
134
|
+
padding: "10px 12px 10px 12px",
|
|
135
|
+
elements: [
|
|
136
|
+
{
|
|
137
|
+
tag: "markdown",
|
|
138
|
+
element_id: `team_member_text_${index}`,
|
|
139
|
+
content: `**${safe(member.label)}** ${roleTag(member.role)}${member.active > 0 ? " <text_tag color='blue'>执行中</text_tag>" : ""}\n` +
|
|
140
|
+
`<font color='grey'>发起 ${member.tasks} · 控制 ${member.controlled} · 成功率 ${successRateLabel(rate)} · tokens ${formatCompact(member.inputTokens + member.outputTokens)}</font>`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function projectPanel(project, index) {
|
|
148
|
+
return {
|
|
149
|
+
tag: "column_set",
|
|
150
|
+
element_id: `team_project_${index}`,
|
|
151
|
+
flex_mode: "none",
|
|
152
|
+
columns: [
|
|
153
|
+
{
|
|
154
|
+
tag: "column",
|
|
155
|
+
width: "weighted",
|
|
156
|
+
weight: 1,
|
|
157
|
+
background_style: project.active > 0 ? "blue-50" : "grey-50",
|
|
158
|
+
padding: "10px 12px 10px 12px",
|
|
159
|
+
elements: [
|
|
160
|
+
{
|
|
161
|
+
tag: "markdown",
|
|
162
|
+
element_id: `team_project_text_${index}`,
|
|
163
|
+
content: `**${safe(project.label)}**${project.active > 0 ? ` <text_tag color='blue'>活跃 ${project.active}</text_tag>` : ""}\n` +
|
|
164
|
+
`<font color='grey'>${project.tasks} 个任务 · ${project.members} 位成员</font>`,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function sectionTitle(id, title, detail) {
|
|
172
|
+
return {
|
|
173
|
+
tag: "markdown",
|
|
174
|
+
element_id: id,
|
|
175
|
+
content: `**${safe(title)}**\n<font color='grey'>${safe(detail)}</font>`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function emptyPanel(id, text) {
|
|
179
|
+
return {
|
|
180
|
+
tag: "column_set",
|
|
181
|
+
element_id: id,
|
|
182
|
+
flex_mode: "none",
|
|
183
|
+
columns: [
|
|
184
|
+
{
|
|
185
|
+
tag: "column",
|
|
186
|
+
width: "weighted",
|
|
187
|
+
weight: 1,
|
|
188
|
+
background_style: "grey-50",
|
|
189
|
+
padding: "10px 12px 10px 12px",
|
|
190
|
+
elements: [{ tag: "markdown", content: `<font color='grey'>${safe(text)}</font>` }],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function feedbackPanel(feedback) {
|
|
196
|
+
return {
|
|
197
|
+
tag: "column_set",
|
|
198
|
+
element_id: "team_feedback",
|
|
199
|
+
flex_mode: "none",
|
|
200
|
+
columns: [
|
|
201
|
+
{
|
|
202
|
+
tag: "column",
|
|
203
|
+
width: "weighted",
|
|
204
|
+
weight: 1,
|
|
205
|
+
background_style: "green-50",
|
|
206
|
+
padding: "10px 12px 10px 12px",
|
|
207
|
+
elements: [
|
|
208
|
+
{
|
|
209
|
+
tag: "markdown",
|
|
210
|
+
content: `<font color='green'>${safe(feedback)}</font>`,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function actionGrid() {
|
|
218
|
+
const actions = [
|
|
219
|
+
button("任务中心", "team_tasks", "primary"),
|
|
220
|
+
button("运行手册", "team_runbooks", "default"),
|
|
221
|
+
button("项目工作台", "team_projects", "default"),
|
|
222
|
+
button("刷新", "team_refresh", "default"),
|
|
223
|
+
];
|
|
224
|
+
return {
|
|
225
|
+
tag: "column_set",
|
|
226
|
+
element_id: "team_actions",
|
|
227
|
+
flex_mode: "none",
|
|
228
|
+
horizontal_spacing: "8px",
|
|
229
|
+
columns: actions.map((action) => ({
|
|
230
|
+
tag: "column",
|
|
231
|
+
width: "weighted",
|
|
232
|
+
weight: 1,
|
|
233
|
+
elements: [action],
|
|
234
|
+
})),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function button(text, action, type) {
|
|
238
|
+
return {
|
|
239
|
+
tag: "button",
|
|
240
|
+
element_id: action,
|
|
241
|
+
text: { tag: "plain_text", content: text },
|
|
242
|
+
type: type === "primary" ? "primary_filled" : "default",
|
|
243
|
+
width: "fill",
|
|
244
|
+
behaviors: [
|
|
245
|
+
{
|
|
246
|
+
type: "callback",
|
|
247
|
+
value: { bridge: "feishu-codex-v5", action },
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function roleTag(role) {
|
|
253
|
+
if (role === "admin")
|
|
254
|
+
return "<text_tag color='violet'>管理员</text_tag>";
|
|
255
|
+
if (role === "operator")
|
|
256
|
+
return "<text_tag color='blue'>操作者</text_tag>";
|
|
257
|
+
return "<text_tag color='neutral'>只读成员</text_tag>";
|
|
258
|
+
}
|
|
259
|
+
function successRateLabel(value) {
|
|
260
|
+
return value === undefined ? "暂无" : `${Math.round(value * 100)}%`;
|
|
261
|
+
}
|
|
262
|
+
function formatNumber(value) {
|
|
263
|
+
return new Intl.NumberFormat("zh-CN").format(value);
|
|
264
|
+
}
|
|
265
|
+
function formatCompact(value) {
|
|
266
|
+
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
|
267
|
+
}
|
|
268
|
+
function safe(value) {
|
|
269
|
+
return value
|
|
270
|
+
.replaceAll("<", "<")
|
|
271
|
+
.replaceAll(">", ">")
|
|
272
|
+
.replaceAll("*", "*")
|
|
273
|
+
.replaceAll("`", "`")
|
|
274
|
+
.slice(0, 160);
|
|
275
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canAccessProject, roleForSender, roleLabel } from "./team-policy.js";
|
|
3
|
+
export function memberSelector(memberId) {
|
|
4
|
+
return createHash("sha256")
|
|
5
|
+
.update(`feishu-codex-member:${memberId}`)
|
|
6
|
+
.digest("hex")
|
|
7
|
+
.slice(0, 20);
|
|
8
|
+
}
|
|
9
|
+
export function memberLabel(config, memberId) {
|
|
10
|
+
const configured = config.memberLabels.get(memberId)?.trim();
|
|
11
|
+
if (configured)
|
|
12
|
+
return configured;
|
|
13
|
+
return `成员 ${memberSelector(memberId).slice(0, 6).toLocaleUpperCase()}`;
|
|
14
|
+
}
|
|
15
|
+
export function teamMembers(config) {
|
|
16
|
+
const ids = new Set([
|
|
17
|
+
...config.adminSenderIds,
|
|
18
|
+
...config.allowedSenderIds,
|
|
19
|
+
...config.viewerSenderIds,
|
|
20
|
+
]);
|
|
21
|
+
return [...ids]
|
|
22
|
+
.map((id) => {
|
|
23
|
+
const role = roleForSender(config, id);
|
|
24
|
+
if (!role)
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
selector: memberSelector(id),
|
|
29
|
+
label: memberLabel(config, id),
|
|
30
|
+
role,
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
.filter((member) => member !== null)
|
|
34
|
+
.sort((left, right) => {
|
|
35
|
+
const roleOrder = roleRank(left.role) - roleRank(right.role);
|
|
36
|
+
return roleOrder || left.label.localeCompare(right.label, "zh-CN");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function operatingTeamMembers(config, project) {
|
|
40
|
+
return teamMembers(config).filter((member) => member.role !== "viewer" && (!project || canAccessProject(config, member.id, project)));
|
|
41
|
+
}
|
|
42
|
+
export function resolveMemberSelector(config, selector) {
|
|
43
|
+
return teamMembers(config).find((member) => member.selector === selector);
|
|
44
|
+
}
|
|
45
|
+
export function teamMemberOptionLabel(member) {
|
|
46
|
+
return `${member.label} · ${roleLabel(member.role)}`;
|
|
47
|
+
}
|
|
48
|
+
function roleRank(role) {
|
|
49
|
+
if (role === "admin")
|
|
50
|
+
return 0;
|
|
51
|
+
if (role === "operator")
|
|
52
|
+
return 1;
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { workspaceSessionForEvent } from "./workspace-session.js";
|
|
2
|
+
export function roleForSender(config, senderId) {
|
|
3
|
+
if (config.adminSenderIds?.has(senderId))
|
|
4
|
+
return "admin";
|
|
5
|
+
if (config.allowedSenderIds.has(senderId))
|
|
6
|
+
return "operator";
|
|
7
|
+
if (config.viewerSenderIds?.has(senderId))
|
|
8
|
+
return "viewer";
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
export function canOperate(config, senderId) {
|
|
12
|
+
const role = roleForSender(config, senderId);
|
|
13
|
+
return role === "admin" || role === "operator";
|
|
14
|
+
}
|
|
15
|
+
export function canAdminister(config, senderId) {
|
|
16
|
+
return roleForSender(config, senderId) === "admin";
|
|
17
|
+
}
|
|
18
|
+
export function canControlOwnedResource(config, senderId, ownerId) {
|
|
19
|
+
return canOperate(config, senderId) && (senderId === ownerId || canAdminister(config, senderId));
|
|
20
|
+
}
|
|
21
|
+
export function canViewOwnedResource(config, senderId, ownerId) {
|
|
22
|
+
return roleForSender(config, senderId) !== null &&
|
|
23
|
+
(senderId === ownerId || canAdminister(config, senderId));
|
|
24
|
+
}
|
|
25
|
+
export function canControlTask(config, senderId, task) {
|
|
26
|
+
if (!canOperate(config, senderId))
|
|
27
|
+
return false;
|
|
28
|
+
return senderId === (task.controllerId || task.ownerId) || canAdminister(config, senderId);
|
|
29
|
+
}
|
|
30
|
+
export function canViewTask(config, senderId, task) {
|
|
31
|
+
if (!roleForSender(config, senderId))
|
|
32
|
+
return false;
|
|
33
|
+
return (senderId === task.ownerId ||
|
|
34
|
+
senderId === (task.controllerId || task.ownerId) ||
|
|
35
|
+
canAdminister(config, senderId));
|
|
36
|
+
}
|
|
37
|
+
export function canTakeOverTask(config, senderId, task) {
|
|
38
|
+
return canOperate(config, senderId) &&
|
|
39
|
+
(senderId === task.ownerId || canAdminister(config, senderId)) &&
|
|
40
|
+
senderId !== (task.controllerId || task.ownerId);
|
|
41
|
+
}
|
|
42
|
+
export function conversationKeyForEvent(event, config) {
|
|
43
|
+
return workspaceSessionForEvent(event, config).conversationKey;
|
|
44
|
+
}
|
|
45
|
+
export function conversationKeyForCard(chatId, operatorId, chatType, config) {
|
|
46
|
+
if (chatType === "group" && config.groupSessionScope === "member") {
|
|
47
|
+
return `${chatId}::${operatorId}`;
|
|
48
|
+
}
|
|
49
|
+
return chatId;
|
|
50
|
+
}
|
|
51
|
+
export function canAccessProject(config, senderId, project) {
|
|
52
|
+
if (!roleForSender(config, senderId))
|
|
53
|
+
return false;
|
|
54
|
+
if (canAdminister(config, senderId) || config.projectAcl.size === 0)
|
|
55
|
+
return true;
|
|
56
|
+
for (const [selector, actors] of config.projectAcl) {
|
|
57
|
+
const normalized = selector.toLocaleLowerCase();
|
|
58
|
+
const matches = selector === "*" ||
|
|
59
|
+
selector === project.path ||
|
|
60
|
+
selector === project.name ||
|
|
61
|
+
selector === project.displayPath ||
|
|
62
|
+
normalized === project.name.toLocaleLowerCase() ||
|
|
63
|
+
normalized === project.displayPath.toLocaleLowerCase();
|
|
64
|
+
if (!matches)
|
|
65
|
+
continue;
|
|
66
|
+
if (actors?.has("*") || actors?.has(senderId))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
export function visibleProjects(config, senderId, projects) {
|
|
72
|
+
return projects.filter((project) => canAccessProject(config, senderId, project));
|
|
73
|
+
}
|
|
74
|
+
const SANDBOX_RANK = {
|
|
75
|
+
"read-only": 0,
|
|
76
|
+
"workspace-write": 1,
|
|
77
|
+
"danger-full-access": 2,
|
|
78
|
+
};
|
|
79
|
+
export function selectableSandboxModes(maximum) {
|
|
80
|
+
return ["read-only", "workspace-write", "danger-full-access"].filter((mode) => SANDBOX_RANK[mode] <= SANDBOX_RANK[maximum]);
|
|
81
|
+
}
|
|
82
|
+
export function isSandboxModeAllowed(mode, maximum) {
|
|
83
|
+
return SANDBOX_RANK[mode] <= SANDBOX_RANK[maximum];
|
|
84
|
+
}
|
|
85
|
+
export function roleLabel(role) {
|
|
86
|
+
if (role === "admin")
|
|
87
|
+
return "管理员";
|
|
88
|
+
if (role === "operator")
|
|
89
|
+
return "操作者";
|
|
90
|
+
if (role === "viewer")
|
|
91
|
+
return "只读成员";
|
|
92
|
+
return "未授权";
|
|
93
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const PACKAGE_ROOT = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
|
|
5
|
+
const manifest = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
6
|
+
export const PACKAGE_NAME = manifest.name;
|
|
7
|
+
export const PACKAGE_VERSION = manifest.version;
|
|
8
|
+
export const CODEX_PACKAGE_VERSION = manifest.dependencies?.["@openai/codex"] ?? "unknown";
|
|
9
|
+
export const LARK_CLI_PACKAGE_VERSION = manifest.dependencies?.["@larksuite/cli"] ?? "unknown";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const TOPIC_MARKER = "::topic::";
|
|
2
|
+
export function workspaceSessionForEvent(event, config) {
|
|
3
|
+
const baseConversationKey = baseConversationForEvent(event, config);
|
|
4
|
+
if (event.chat_type === "p2p") {
|
|
5
|
+
return {
|
|
6
|
+
conversationKey: event.chat_id,
|
|
7
|
+
baseConversationKey: event.chat_id,
|
|
8
|
+
kind: "direct",
|
|
9
|
+
replyInThread: false,
|
|
10
|
+
startsNewTopic: false,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const topicRootId = normalizedTopicId(event.root_id) ?? normalizedTopicId(event.thread_id);
|
|
14
|
+
if (topicRootId) {
|
|
15
|
+
return {
|
|
16
|
+
conversationKey: topicConversationKey(event.chat_id, topicRootId),
|
|
17
|
+
baseConversationKey,
|
|
18
|
+
kind: "group-topic",
|
|
19
|
+
replyInThread: true,
|
|
20
|
+
startsNewTopic: false,
|
|
21
|
+
topicRootId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
conversationKey: baseConversationKey,
|
|
26
|
+
baseConversationKey,
|
|
27
|
+
kind: "group-main",
|
|
28
|
+
replyInThread: false,
|
|
29
|
+
startsNewTopic: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function workspaceSessionForPrompt(event, config) {
|
|
33
|
+
const current = workspaceSessionForEvent(event, config);
|
|
34
|
+
if (current.kind !== "group-main")
|
|
35
|
+
return current;
|
|
36
|
+
return {
|
|
37
|
+
conversationKey: topicConversationKey(event.chat_id, event.message_id),
|
|
38
|
+
baseConversationKey: current.baseConversationKey,
|
|
39
|
+
kind: "group-topic",
|
|
40
|
+
replyInThread: true,
|
|
41
|
+
startsNewTopic: true,
|
|
42
|
+
topicRootId: event.message_id,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function baseConversationForEvent(event, config) {
|
|
46
|
+
if (event.chat_type === "group" && config.groupSessionScope === "member") {
|
|
47
|
+
return `${event.chat_id}::${event.sender_id}`;
|
|
48
|
+
}
|
|
49
|
+
return event.chat_id;
|
|
50
|
+
}
|
|
51
|
+
export function topicConversationKey(chatId, topicRootId) {
|
|
52
|
+
return `${chatId}${TOPIC_MARKER}${topicRootId}`;
|
|
53
|
+
}
|
|
54
|
+
export function isTopicConversationKey(conversationKey) {
|
|
55
|
+
return conversationKey.includes(TOPIC_MARKER);
|
|
56
|
+
}
|
|
57
|
+
export function chatIdFromConversationKey(conversationKey) {
|
|
58
|
+
const boundary = conversationKey.indexOf("::");
|
|
59
|
+
return boundary >= 0 ? conversationKey.slice(0, boundary) : conversationKey;
|
|
60
|
+
}
|
|
61
|
+
function normalizedTopicId(value) {
|
|
62
|
+
const normalized = value?.trim();
|
|
63
|
+
return normalized || undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Feishu Codex Bridge is a self-hosted, outbound-only control plane. The bridge opens long-lived connections to Feishu and a local Codex app-server; it does not expose SSH, HTTP, or a webhook port from the developer machine.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart LR
|
|
7
|
+
F["Feishu messages and CardKit"] --> R["Workspace-session router"]
|
|
8
|
+
R --> G["Identity, role, chat and project gates"]
|
|
9
|
+
G --> S["SQLite state, audit and outbox"]
|
|
10
|
+
S --> Q["Per-workspace-session task queue"]
|
|
11
|
+
Q --> C["Persistent Codex app-server"]
|
|
12
|
+
C --> W["Authorized local workspaces"]
|
|
13
|
+
C --> I["Progress, approvals and questions"]
|
|
14
|
+
I --> P["Editable Markdown or Card 2.0"]
|
|
15
|
+
P --> F
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Identity and isolation
|
|
19
|
+
|
|
20
|
+
- Private chats use the Feishu chat ID as their current workspace-session key.
|
|
21
|
+
- Top-level group controls keep the configured `chat` or `member` isolation policy. A top-level ordinary prompt starts a reply thread whose key is `chat_id::topic::root_message_id`; every later message in that reply thread resolves to the same project, queue, preferences, and Codex thread.
|
|
22
|
+
- A new group topic copies the top-level project and explicit model/reasoning/sandbox preferences once. It never copies an old Codex thread, temporary full-access lease, pending approval, or unfinished task.
|
|
23
|
+
- First-run onboarding is stored per Feishu member and is automatically presented only in private chat, so group work is not interrupted. A single state-driven card renders the device-offline, project-missing, viewer, ready, completed, or dismissed surface; each active surface has one primary action. Team conventions are progressive disclosure rather than a required setup step.
|
|
24
|
+
- Topic history is session-owned, while execution authority remains explicit. Tasks store an immutable initiator and a current controller: control can move only through a group handoff, initiator reclaim, or administrator takeover after role and project ACL checks. Viewers cannot start or control Codex work.
|
|
25
|
+
- Project ACLs are evaluated after project discovery, so a message can never introduce an arbitrary filesystem path.
|
|
26
|
+
- Team dashboards aggregate retained task metadata without rendering prompts, results, diffs, attachments, paths, or raw member IDs.
|
|
27
|
+
|
|
28
|
+
## Codex lifecycle
|
|
29
|
+
|
|
30
|
+
One persistent app-server process serves all conversations. The bridge uses native `model/list`, `thread/list`, `thread/resume`, `thread/compact/start`, `turn/start`, `turn/steer`, and `turn/interrupt` methods. A task snapshots its model, reasoning effort, and sandbox mode when it enters the queue, so later settings changes do not mutate an already queued task.
|
|
31
|
+
|
|
32
|
+
Repository runbooks are parsed as data, not executed as shell scripts. Parameter substitution produces a normal Codex prompt, then the same member, project, model compatibility, sandbox and command policies are applied. A runbook can lower the frozen sandbox but cannot elevate it or pre-authorize external mutations.
|
|
33
|
+
|
|
34
|
+
## Durability
|
|
35
|
+
|
|
36
|
+
SQLite WAL stores conversations, tasks, project selections, preferences, onboarding progress, confirmation cards, event deduplication, and device state. A separate outbox table retries text fallbacks. Audit records are bounded to the newest 5,000 entries. Only tasks that can be proven never to have started are restored to the queue; any running, started, or thread-bearing record is interrupted to prevent duplicate commands and file mutations. The task record and CardKit phase are reconciled conservatively at startup. Schema upgrades create a verified SQLite snapshot first and restore it automatically if migration fails.
|
|
37
|
+
|
|
38
|
+
## Presentation boundary
|
|
39
|
+
|
|
40
|
+
Questions and read-only analysis own one editable Feishu Markdown reply. That message starts as a compact processing state, is updated in place while Codex works, and becomes a clean final answer without task IDs, model, permission, token, or thread metadata. Routine acknowledgements and validation feedback use the same lightweight native surface.
|
|
41
|
+
|
|
42
|
+
Native text/post editing uses `PUT /im/v1/messages/:message_id`. Feishu limits one message to 20 edits, so stream deltas are coalesced and progress edits are capped below that limit; a terminal edit is always reserved for the final answer or error state.
|
|
43
|
+
|
|
44
|
+
Feature cards own controls, project and model settings, approvals, runtime questions, long-running file/code work, reviews, teams, runbooks, and exceptional recovery states. Card rendering remains semantic and state-driven; the durable outbox can fall back to plain text when either Markdown or CardKit delivery fails. This keeps the conversation readable while preserving reliable control for consequential actions.
|
|
45
|
+
|
|
46
|
+
## Version and adapter boundary
|
|
47
|
+
|
|
48
|
+
Configuration, persisted state, SQLite, repository policy, runbooks and health files each carry an explicit version. Published packages pin Codex and lark-cli exactly. A safe package upgrade treats the running service as replaceable but the user configuration and data directory as durable: preview, active-task gate, verified backup, doctor, service replacement, health/version verification, then automatic recovery on failure.
|
|
49
|
+
|
|
50
|
+
Feishu event transport and Codex app-server normalization are internal adapters around the identity/task/policy/persistence domain. They are intentionally not a public plugin SDK in `1.x`; exposing security-critical runtime objects would freeze unsafe implementation details. Future IM or device agents should adapt at these boundaries only after a stable capability, ownership and version-negotiation contract exists.
|
|
51
|
+
|
|
52
|
+
## Multi-device direction
|
|
53
|
+
|
|
54
|
+
V5 supports many people on one self-hosted agent node. Multiple machines should not consume the same Feishu application event stream independently because delivery and ownership would be ambiguous. A future hub/agent mode should route tasks through one Feishu-facing hub to outbound-connected device agents, with explicit device identity, project advertisements, end-to-end task ownership, revocation, and no public port on developer laptops.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Compatibility and version policy
|
|
2
|
+
|
|
3
|
+
This page is the release contract for operators and package maintainers. The npm package pins its two runtime integrations so a published bridge does not silently change behavior underneath an installation.
|
|
4
|
+
|
|
5
|
+
## Current release line
|
|
6
|
+
|
|
7
|
+
| Component | Supported contract |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Node.js | `>=22` on current macOS or Linux |
|
|
10
|
+
| Codex runtime | @openai/codex `0.144.4` |
|
|
11
|
+
| Feishu runtime | @larksuite/cli `1.0.70` |
|
|
12
|
+
| Configuration | `BRIDGE_CONFIG_VERSION=1` |
|
|
13
|
+
| Persisted bridge state | persisted state v6, migrated on load |
|
|
14
|
+
| SQLite | SQLite schema v1, backed up before migration |
|
|
15
|
+
| Background service | macOS LaunchAgent or Linux systemd user service |
|
|
16
|
+
|
|
17
|
+
Run `feishu-codex-bridge version` or `feishu-codex-bridge version --json` to print the exact contract installed on a machine.
|
|
18
|
+
|
|
19
|
+
## Semantic Versioning
|
|
20
|
+
|
|
21
|
+
- Patch releases fix defects without intentionally changing configuration or persisted-state meaning.
|
|
22
|
+
- Minor releases may add optional settings, cards, commands, and additive stored fields. Existing configuration remains valid.
|
|
23
|
+
- Major releases may remove or redefine a public CLI/configuration contract and must include an explicit migration guide.
|
|
24
|
+
- Prereleases can evolve faster, but never bypass data backup, role checks, or external-action confirmation.
|
|
25
|
+
|
|
26
|
+
The stable `1.x` promise covers documented CLI commands, configuration keys, runbook schema v1, policy schema v1, and automatic persisted-state migration. Internal TypeScript modules and CardKit payload shapes are not a public plugin API.
|
|
27
|
+
|
|
28
|
+
## Upgrade and rollback
|
|
29
|
+
|
|
30
|
+
Preview an upgrade first:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx feishu-codex-console@latest upgrade --config /absolute/path/default.env
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The preview is read-only. When no task is actively running, repeat with `--yes`. The command creates a verified SQLite backup, runs the new package’s doctor, stops the old service, installs the new service, and waits for both Feishu consumers to become healthy. Failure triggers a data rollback and, when the previous package location is still available, reinstalls and verifies the previous service.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx feishu-codex-console@latest upgrade \
|
|
40
|
+
--config /absolute/path/default.env \
|
|
41
|
+
--yes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Downgrades require `--allow-downgrade`; compatibility is not guaranteed across a major version. Keep the reported backup ID until the new version has handled a real task successfully.
|
|
45
|
+
|
|
46
|
+
## Dependency updates
|
|
47
|
+
|
|
48
|
+
Changing either pinned runtime dependency requires:
|
|
49
|
+
|
|
50
|
+
1. capability-aware unit tests and a clean tarball installation test;
|
|
51
|
+
2. macOS and Linux CI;
|
|
52
|
+
3. a real Feishu message, card callback, Codex task, question, approval, and review pass;
|
|
53
|
+
4. a changelog entry describing any visible model, reasoning, event, or card behavior change.
|
|
54
|
+
|
|
55
|
+
Experimental Codex capabilities must be feature-detected and cannot be required for the core task loop.
|