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,233 @@
|
|
|
1
|
+
export function renderAccountQuotaSummary(snapshot, prefix, detailsAction) {
|
|
2
|
+
const elements = [];
|
|
3
|
+
if (snapshot.status === "unavailable") {
|
|
4
|
+
elements.push(markdown(`${prefix}_sum_title`, "**账户额度** <text_tag color='neutral'>暂不可用</text_tag>"), markdown(`${prefix}_sum_body`, `<font color='grey'>${safe(snapshot.message)}</font>`, "notation"));
|
|
5
|
+
}
|
|
6
|
+
else {
|
|
7
|
+
const visible = snapshot.limits.slice(0, 3);
|
|
8
|
+
const plan = visible.map((limit) => limit.planType).find(Boolean);
|
|
9
|
+
const summary = visible.map((limit) => {
|
|
10
|
+
const window = limit.primary ?? limit.secondary;
|
|
11
|
+
return window
|
|
12
|
+
? `**${safe(shortLimitName(limit))} ${percent(window.remainingPercent)}**`
|
|
13
|
+
: `**${safe(shortLimitName(limit))} 暂无数据**`;
|
|
14
|
+
}).join(" · ");
|
|
15
|
+
const nearestReset = visible
|
|
16
|
+
.flatMap((limit) => [limit.primary, limit.secondary])
|
|
17
|
+
.filter((window) => Boolean(window?.resetsAt))
|
|
18
|
+
.sort((left, right) => Date.parse(left.resetsAt ?? "") - Date.parse(right.resetsAt ?? ""))[0];
|
|
19
|
+
const note = [
|
|
20
|
+
nearestReset?.resetsAt ? `最近 ${formatResetTime(nearestReset.resetsAt)} 重置` : "",
|
|
21
|
+
snapshot.resetCredits > 0 ? `${snapshot.resetCredits} 次可用重置` : "",
|
|
22
|
+
].filter(Boolean).join(" · ");
|
|
23
|
+
elements.push(markdown(`${prefix}_sum_title`, `**账户额度**${plan ? ` <text_tag color='neutral'>${safe(planLabel(plan))}</text_tag>` : ""}`), markdown(`${prefix}_sum_body`, summary || "<font color='grey'>暂未返回额度窗口</font>"), ...(note
|
|
24
|
+
? [markdown(`${prefix}_sum_note`, `<font color='grey'>${safe(note)}</font>`, "notation")]
|
|
25
|
+
: []));
|
|
26
|
+
}
|
|
27
|
+
if (detailsAction) {
|
|
28
|
+
elements.push({
|
|
29
|
+
tag: "button",
|
|
30
|
+
element_id: `${prefix}_quota_more`,
|
|
31
|
+
text: { tag: "plain_text", content: "查看额度详情" },
|
|
32
|
+
type: "default",
|
|
33
|
+
width: "fill",
|
|
34
|
+
behaviors: [
|
|
35
|
+
{
|
|
36
|
+
type: "callback",
|
|
37
|
+
value: detailsAction,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return panel(`${prefix}_sum`, "grey-50", elements);
|
|
43
|
+
}
|
|
44
|
+
export function renderAccountQuotaPanel(snapshot, prefix) {
|
|
45
|
+
if (snapshot.status === "unavailable") {
|
|
46
|
+
return panel(prefix, "grey-50", [
|
|
47
|
+
markdown(`${prefix}_title`, "**账户额度** <text_tag color='neutral'>暂不可用</text_tag>"),
|
|
48
|
+
markdown(`${prefix}_body`, `<font color='grey'>${safe(snapshot.message)}\n采样 ${safe(formatSampleTime(snapshot.sampledAt))}</font>`, "notation"),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
const visible = snapshot.limits.slice(0, 4);
|
|
52
|
+
const remaining = visible.flatMap((limit) => [limit.primary, limit.secondary]
|
|
53
|
+
.filter((window) => Boolean(window))
|
|
54
|
+
.map((window) => window.remainingPercent));
|
|
55
|
+
const minimumRemaining = remaining.length > 0 ? Math.min(...remaining) : 100;
|
|
56
|
+
const reached = visible.some((limit) => Boolean(limit.reachedType));
|
|
57
|
+
const tone = reached || minimumRemaining <= 10
|
|
58
|
+
? { background: "red-50", color: "red" }
|
|
59
|
+
: minimumRemaining <= 25
|
|
60
|
+
? { background: "orange-50", color: "orange" }
|
|
61
|
+
: { background: "blue-50", color: "blue" };
|
|
62
|
+
const plan = visible.map((limit) => limit.planType).find(Boolean);
|
|
63
|
+
const body = visible.length > 0
|
|
64
|
+
? visible.map(renderLimit).join("\n\n")
|
|
65
|
+
: "<font color='grey'>当前账户没有返回可展示的额度窗口。</font>";
|
|
66
|
+
const footer = [
|
|
67
|
+
snapshot.resetCredits > 0 ? `可用重置次数 ${snapshot.resetCredits}` : "",
|
|
68
|
+
`采样 ${formatSampleTime(snapshot.sampledAt)}`,
|
|
69
|
+
].filter(Boolean).join(" · ");
|
|
70
|
+
return panel(prefix, tone.background, [
|
|
71
|
+
markdown(`${prefix}_title`, `**<font color='${tone.color}'>账户额度</font>**${plan ? ` <text_tag color='neutral'>${safe(planLabel(plan))}</text_tag>` : ""}`),
|
|
72
|
+
markdown(`${prefix}_body`, body),
|
|
73
|
+
markdown(`${prefix}_foot`, `<font color='grey'>${safe(footer)}</font>`, "notation"),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
export function formatAccountQuotaText(snapshot) {
|
|
77
|
+
if (snapshot.status === "unavailable")
|
|
78
|
+
return [`- 账户额度:${snapshot.message}`];
|
|
79
|
+
const lines = snapshot.limits.flatMap((limit) => {
|
|
80
|
+
const windows = [limit.primary, limit.secondary]
|
|
81
|
+
.filter((window) => Boolean(window))
|
|
82
|
+
.map((window) => `${windowLabel(window)}剩余 ${percent(window.remainingPercent)}${resetLabel(window)}`);
|
|
83
|
+
return [`- ${limit.name}:${windows.join(";") || "未返回额度窗口"}`];
|
|
84
|
+
});
|
|
85
|
+
if (snapshot.resetCredits > 0)
|
|
86
|
+
lines.push(`- 可用额度重置次数:${snapshot.resetCredits}`);
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
function renderLimit(limit) {
|
|
90
|
+
const windows = [limit.primary, limit.secondary]
|
|
91
|
+
.filter((window) => Boolean(window))
|
|
92
|
+
.map((window) => renderWindow(window));
|
|
93
|
+
const details = [
|
|
94
|
+
...windows,
|
|
95
|
+
creditsLabel(limit),
|
|
96
|
+
spendLimitLabel(limit),
|
|
97
|
+
reachedLabel(limit.reachedType),
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
return `**${safe(limit.name)}**${limit.id === "codex" ? "" : " <text_tag color='neutral'>独立额度</text_tag>"}\n${details.join("\n") || "<font color='grey'>暂未返回额度窗口</font>"}`;
|
|
100
|
+
}
|
|
101
|
+
function shortLimitName(limit) {
|
|
102
|
+
if (limit.id === "codex")
|
|
103
|
+
return "Codex";
|
|
104
|
+
if (/spark/i.test(limit.name))
|
|
105
|
+
return "Spark";
|
|
106
|
+
return limit.name.length > 18 ? `${limit.name.slice(0, 17)}…` : limit.name;
|
|
107
|
+
}
|
|
108
|
+
function renderWindow(window) {
|
|
109
|
+
const color = window.remainingPercent <= 10 ? "red" : window.remainingPercent <= 25 ? "orange" : "green";
|
|
110
|
+
return `${quotaBar(window.remainingPercent)} **<font color='${color}'>剩余 ${percent(window.remainingPercent)}</font>**\n<font color='grey'>${safe(windowLabel(window))}${safe(resetLabel(window))}</font>`;
|
|
111
|
+
}
|
|
112
|
+
function creditsLabel(limit) {
|
|
113
|
+
if (limit.credits?.unlimited)
|
|
114
|
+
return "<font color='grey'>积分额度不限</font>";
|
|
115
|
+
if (limit.credits?.hasCredits && limit.credits.balance) {
|
|
116
|
+
return `<font color='grey'>积分余额 ${safe(limit.credits.balance)}</font>`;
|
|
117
|
+
}
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
function spendLimitLabel(limit) {
|
|
121
|
+
const spend = limit.individualLimit;
|
|
122
|
+
if (!spend)
|
|
123
|
+
return "";
|
|
124
|
+
const reset = spend.resetsAt ? ` · ${formatResetTime(spend.resetsAt)} 重置` : "";
|
|
125
|
+
return `<font color='grey'>个人用量上限剩余 ${percent(spend.remainingPercent)} · ${safe(spend.used)} / ${safe(spend.limit)}${safe(reset)}</font>`;
|
|
126
|
+
}
|
|
127
|
+
function reachedLabel(reachedType) {
|
|
128
|
+
if (!reachedType)
|
|
129
|
+
return "";
|
|
130
|
+
return `<font color='red'>当前额度已受限:${safe(reachedTypeLabel(reachedType))}</font>`;
|
|
131
|
+
}
|
|
132
|
+
function quotaBar(remainingPercent) {
|
|
133
|
+
const filled = Math.max(0, Math.min(10, Math.round(remainingPercent / 10)));
|
|
134
|
+
return `${"■".repeat(filled)}${"□".repeat(10 - filled)}`;
|
|
135
|
+
}
|
|
136
|
+
function windowLabel(window) {
|
|
137
|
+
const minutes = window.windowDurationMins;
|
|
138
|
+
if (!minutes)
|
|
139
|
+
return "滚动窗口";
|
|
140
|
+
if (minutes % (24 * 60) === 0)
|
|
141
|
+
return `${minutes / (24 * 60)} 天窗口`;
|
|
142
|
+
if (minutes % 60 === 0)
|
|
143
|
+
return `${minutes / 60} 小时窗口`;
|
|
144
|
+
return `${minutes} 分钟窗口`;
|
|
145
|
+
}
|
|
146
|
+
function resetLabel(window) {
|
|
147
|
+
return window.resetsAt ? ` · ${formatResetTime(window.resetsAt)} 重置` : "";
|
|
148
|
+
}
|
|
149
|
+
function formatResetTime(value) {
|
|
150
|
+
const date = new Date(value);
|
|
151
|
+
if (!Number.isFinite(date.getTime()))
|
|
152
|
+
return "时间未知";
|
|
153
|
+
return date.toLocaleString("zh-CN", {
|
|
154
|
+
month: "2-digit",
|
|
155
|
+
day: "2-digit",
|
|
156
|
+
hour: "2-digit",
|
|
157
|
+
minute: "2-digit",
|
|
158
|
+
hour12: false,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function formatSampleTime(value) {
|
|
162
|
+
const date = new Date(value);
|
|
163
|
+
if (!Number.isFinite(date.getTime()))
|
|
164
|
+
return "时间未知";
|
|
165
|
+
return date.toLocaleTimeString("zh-CN", {
|
|
166
|
+
hour: "2-digit",
|
|
167
|
+
minute: "2-digit",
|
|
168
|
+
hour12: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function percent(value) {
|
|
172
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)}%`;
|
|
173
|
+
}
|
|
174
|
+
function planLabel(value) {
|
|
175
|
+
return {
|
|
176
|
+
free: "Free",
|
|
177
|
+
go: "Go",
|
|
178
|
+
plus: "Plus",
|
|
179
|
+
pro: "Pro",
|
|
180
|
+
prolite: "Pro Lite",
|
|
181
|
+
team: "Team",
|
|
182
|
+
self_serve_business_usage_based: "Business",
|
|
183
|
+
business: "Business",
|
|
184
|
+
enterprise_cbp_usage_based: "Enterprise",
|
|
185
|
+
enterprise: "Enterprise",
|
|
186
|
+
edu: "Edu",
|
|
187
|
+
unknown: "Codex",
|
|
188
|
+
}[value] ?? value;
|
|
189
|
+
}
|
|
190
|
+
function reachedTypeLabel(value) {
|
|
191
|
+
return {
|
|
192
|
+
rate_limit_reached: "滚动额度已用尽",
|
|
193
|
+
workspace_owner_credits_depleted: "工作区积分已用尽",
|
|
194
|
+
workspace_member_credits_depleted: "成员积分已用尽",
|
|
195
|
+
workspace_owner_usage_limit_reached: "工作区用量上限已达到",
|
|
196
|
+
workspace_member_usage_limit_reached: "成员用量上限已达到",
|
|
197
|
+
}[value] ?? value;
|
|
198
|
+
}
|
|
199
|
+
function panel(prefix, background, elements) {
|
|
200
|
+
return {
|
|
201
|
+
tag: "column_set",
|
|
202
|
+
element_id: `${prefix}_panel`,
|
|
203
|
+
flex_mode: "none",
|
|
204
|
+
columns: [
|
|
205
|
+
{
|
|
206
|
+
tag: "column",
|
|
207
|
+
width: "weighted",
|
|
208
|
+
weight: 1,
|
|
209
|
+
background_style: background,
|
|
210
|
+
padding: "12px 12px 12px 12px",
|
|
211
|
+
vertical_spacing: "6px",
|
|
212
|
+
elements,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function markdown(elementId, content, textSize) {
|
|
218
|
+
return {
|
|
219
|
+
tag: "markdown",
|
|
220
|
+
element_id: elementId,
|
|
221
|
+
...(textSize ? { text_size: textSize } : {}),
|
|
222
|
+
content,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function safe(value) {
|
|
226
|
+
return value
|
|
227
|
+
.replaceAll("&", "&")
|
|
228
|
+
.replaceAll("<", "<")
|
|
229
|
+
.replaceAll(">", ">")
|
|
230
|
+
.replaceAll("*", "*")
|
|
231
|
+
.replaceAll("_", "_")
|
|
232
|
+
.replaceAll("`", "`");
|
|
233
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function normalizeAccountQuotaResponse(value, sampledAt = new Date().toISOString()) {
|
|
2
|
+
const response = asRecord(value);
|
|
3
|
+
const byLimitId = asRecord(response.rateLimitsByLimitId);
|
|
4
|
+
const candidates = [
|
|
5
|
+
{ value: response.rateLimits },
|
|
6
|
+
...Object.entries(byLimitId).map(([fallbackId, entry]) => ({ value: entry, fallbackId })),
|
|
7
|
+
];
|
|
8
|
+
const limits = new Map();
|
|
9
|
+
for (const candidate of candidates) {
|
|
10
|
+
const limit = normalizeLimit(candidate.value, candidate.fallbackId);
|
|
11
|
+
if (limit && !limits.has(limit.id))
|
|
12
|
+
limits.set(limit.id, limit);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
status: "available",
|
|
16
|
+
sampledAt,
|
|
17
|
+
limits: [...limits.values()],
|
|
18
|
+
resetCredits: nonNegativeInteger(asRecord(response.rateLimitResetCredits).availableCount),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function unavailableAccountQuota(sampledAt = new Date().toISOString()) {
|
|
22
|
+
return {
|
|
23
|
+
status: "unavailable",
|
|
24
|
+
sampledAt,
|
|
25
|
+
message: "Codex 暂未返回账户额度,请确认登录状态后刷新。",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function normalizeLimit(value, fallbackId) {
|
|
29
|
+
const raw = asRecord(value);
|
|
30
|
+
if (Object.keys(raw).length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
const id = stringValue(raw.limitId) ?? fallbackId ?? "codex";
|
|
33
|
+
const name = stringValue(raw.limitName) ?? (id === "codex" ? "Codex" : id);
|
|
34
|
+
const primary = normalizeWindow(raw.primary);
|
|
35
|
+
const secondary = normalizeWindow(raw.secondary);
|
|
36
|
+
const credits = normalizeCredits(raw.credits);
|
|
37
|
+
const individualLimit = normalizeSpendLimit(raw.individualLimit);
|
|
38
|
+
const planType = stringValue(raw.planType);
|
|
39
|
+
const reachedType = stringValue(raw.rateLimitReachedType);
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
...(planType ? { planType } : {}),
|
|
44
|
+
...(primary ? { primary } : {}),
|
|
45
|
+
...(secondary ? { secondary } : {}),
|
|
46
|
+
...(credits ? { credits } : {}),
|
|
47
|
+
...(individualLimit ? { individualLimit } : {}),
|
|
48
|
+
...(reachedType ? { reachedType } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function normalizeWindow(value) {
|
|
52
|
+
const raw = asRecord(value);
|
|
53
|
+
const used = finiteNumber(raw.usedPercent);
|
|
54
|
+
if (used === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
const usedPercent = clamp(used, 0, 100);
|
|
57
|
+
const duration = finiteNumber(raw.windowDurationMins);
|
|
58
|
+
const resetsAt = timestampIso(raw.resetsAt);
|
|
59
|
+
return {
|
|
60
|
+
usedPercent,
|
|
61
|
+
remainingPercent: clamp(100 - usedPercent, 0, 100),
|
|
62
|
+
...(duration !== undefined && duration > 0 ? { windowDurationMins: duration } : {}),
|
|
63
|
+
...(resetsAt ? { resetsAt } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function normalizeCredits(value) {
|
|
67
|
+
const raw = asRecord(value);
|
|
68
|
+
if (Object.keys(raw).length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
const balance = stringValue(raw.balance);
|
|
71
|
+
return {
|
|
72
|
+
hasCredits: raw.hasCredits === true,
|
|
73
|
+
unlimited: raw.unlimited === true,
|
|
74
|
+
...(balance !== undefined ? { balance } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function normalizeSpendLimit(value) {
|
|
78
|
+
const raw = asRecord(value);
|
|
79
|
+
const limit = stringValue(raw.limit);
|
|
80
|
+
const used = stringValue(raw.used);
|
|
81
|
+
const remaining = finiteNumber(raw.remainingPercent);
|
|
82
|
+
if (!limit || !used || remaining === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
const resetsAt = timestampIso(raw.resetsAt);
|
|
85
|
+
return {
|
|
86
|
+
limit,
|
|
87
|
+
used,
|
|
88
|
+
remainingPercent: clamp(remaining, 0, 100),
|
|
89
|
+
...(resetsAt ? { resetsAt } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function timestampIso(value) {
|
|
93
|
+
const timestamp = finiteNumber(value);
|
|
94
|
+
if (timestamp === undefined || timestamp <= 0)
|
|
95
|
+
return undefined;
|
|
96
|
+
const date = new Date(timestamp < 1_000_000_000_000 ? timestamp * 1_000 : timestamp);
|
|
97
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
98
|
+
}
|
|
99
|
+
function finiteNumber(value) {
|
|
100
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
101
|
+
return value;
|
|
102
|
+
if (typeof value === "string" && value.trim()) {
|
|
103
|
+
const parsed = Number(value);
|
|
104
|
+
if (Number.isFinite(parsed))
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
function nonNegativeInteger(value) {
|
|
110
|
+
if (typeof value === "bigint")
|
|
111
|
+
return Number(value > 0n ? value : 0n);
|
|
112
|
+
const parsed = finiteNumber(value);
|
|
113
|
+
return parsed === undefined ? 0 : Math.max(0, Math.trunc(parsed));
|
|
114
|
+
}
|
|
115
|
+
function stringValue(value) {
|
|
116
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
117
|
+
}
|
|
118
|
+
function asRecord(value) {
|
|
119
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
120
|
+
? value
|
|
121
|
+
: {};
|
|
122
|
+
}
|
|
123
|
+
function clamp(value, minimum, maximum) {
|
|
124
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
125
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { redactSensitiveText, safeErrorText } from "./redaction.js";
|
|
6
|
+
export class AppServerRpcError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
data;
|
|
9
|
+
constructor(message, code, data) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.data = data;
|
|
13
|
+
this.name = "AppServerRpcError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class CodexAppServerClient {
|
|
17
|
+
options;
|
|
18
|
+
child = null;
|
|
19
|
+
lines = null;
|
|
20
|
+
nextId = 1;
|
|
21
|
+
pending = new Map();
|
|
22
|
+
startPromise = null;
|
|
23
|
+
closing = false;
|
|
24
|
+
health = { ready: false, restartCount: 0 };
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async start() {
|
|
29
|
+
if (this.health.ready && this.child)
|
|
30
|
+
return;
|
|
31
|
+
if (this.startPromise)
|
|
32
|
+
return this.startPromise;
|
|
33
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
34
|
+
this.startPromise = null;
|
|
35
|
+
});
|
|
36
|
+
return this.startPromise;
|
|
37
|
+
}
|
|
38
|
+
async request(method, params, timeoutMs) {
|
|
39
|
+
await this.start();
|
|
40
|
+
return this.sendRequest(method, params, timeoutMs);
|
|
41
|
+
}
|
|
42
|
+
notify(method, params) {
|
|
43
|
+
if (!this.child || !this.child.stdin.writable) {
|
|
44
|
+
throw new Error("Codex app-server is not connected");
|
|
45
|
+
}
|
|
46
|
+
this.write({ method, ...(params === undefined ? {} : { params }) });
|
|
47
|
+
}
|
|
48
|
+
getHealth() {
|
|
49
|
+
return { ...this.health };
|
|
50
|
+
}
|
|
51
|
+
async close() {
|
|
52
|
+
this.closing = true;
|
|
53
|
+
this.health.ready = false;
|
|
54
|
+
const child = this.child;
|
|
55
|
+
if (!child)
|
|
56
|
+
return;
|
|
57
|
+
this.child = null;
|
|
58
|
+
this.lines?.close();
|
|
59
|
+
this.lines = null;
|
|
60
|
+
rejectPending(this.pending, new Error("Codex app-server closed"));
|
|
61
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
62
|
+
return;
|
|
63
|
+
await new Promise((resolve) => {
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
if (!child.killed)
|
|
66
|
+
child.kill("SIGKILL");
|
|
67
|
+
resolve();
|
|
68
|
+
}, 2_000);
|
|
69
|
+
timer.unref();
|
|
70
|
+
child.once("exit", () => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
child.kill("SIGTERM");
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async startInternal() {
|
|
78
|
+
this.closing = false;
|
|
79
|
+
const command = this.options.command ?? resolveBundledCodexCommand();
|
|
80
|
+
const child = spawn(command.command, command.args, {
|
|
81
|
+
env: this.options.env,
|
|
82
|
+
shell: false,
|
|
83
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
84
|
+
});
|
|
85
|
+
this.child = child;
|
|
86
|
+
this.health.ready = false;
|
|
87
|
+
this.health.startedAt = Date.now();
|
|
88
|
+
if (child.pid !== undefined)
|
|
89
|
+
this.health.pid = child.pid;
|
|
90
|
+
else
|
|
91
|
+
delete this.health.pid;
|
|
92
|
+
delete this.health.lastError;
|
|
93
|
+
let stderrTail = "";
|
|
94
|
+
child.stderr.setEncoding("utf8");
|
|
95
|
+
child.stderr.on("data", (chunk) => {
|
|
96
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4_000);
|
|
97
|
+
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
|
|
98
|
+
console.error(`[codex-app-server] ${redactSensitiveText(line, 1_000)}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const spawned = new Promise((resolve, reject) => {
|
|
102
|
+
child.once("spawn", resolve);
|
|
103
|
+
child.once("error", reject);
|
|
104
|
+
});
|
|
105
|
+
child.once("exit", (code, signal) => {
|
|
106
|
+
this.handleExit(child, code, signal, stderrTail);
|
|
107
|
+
});
|
|
108
|
+
this.lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
109
|
+
this.lines.on("line", (line) => this.handleLine(line));
|
|
110
|
+
try {
|
|
111
|
+
await spawned;
|
|
112
|
+
await this.sendRequest("initialize", {
|
|
113
|
+
clientInfo: {
|
|
114
|
+
name: "feishu_codex_bridge",
|
|
115
|
+
title: "Feishu Remote for Codex",
|
|
116
|
+
version: "1.0.0-beta.2",
|
|
117
|
+
},
|
|
118
|
+
capabilities: {
|
|
119
|
+
experimentalApi: true,
|
|
120
|
+
requestAttestation: false,
|
|
121
|
+
},
|
|
122
|
+
}, 20_000);
|
|
123
|
+
this.notify("initialized", {});
|
|
124
|
+
this.health.ready = true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.health.lastError = safeErrorText(error);
|
|
128
|
+
if (!child.killed)
|
|
129
|
+
child.kill("SIGTERM");
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
sendRequest(method, params, timeoutMs) {
|
|
134
|
+
if (!this.child || !this.child.stdin.writable) {
|
|
135
|
+
return Promise.reject(new Error("Codex app-server is not connected"));
|
|
136
|
+
}
|
|
137
|
+
const id = this.nextId++;
|
|
138
|
+
const timeout = setTimeout(() => {
|
|
139
|
+
const pending = this.pending.get(String(id));
|
|
140
|
+
if (!pending)
|
|
141
|
+
return;
|
|
142
|
+
this.pending.delete(String(id));
|
|
143
|
+
pending.reject(new Error(`Codex app-server request timed out: ${method}`));
|
|
144
|
+
}, timeoutMs ?? this.options.requestTimeoutMs ?? 30_000);
|
|
145
|
+
timeout.unref();
|
|
146
|
+
const result = new Promise((resolve, reject) => {
|
|
147
|
+
this.pending.set(String(id), {
|
|
148
|
+
method,
|
|
149
|
+
resolve: (value) => resolve(value),
|
|
150
|
+
reject,
|
|
151
|
+
timeout,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
this.write({ method, id, ...(params === undefined ? {} : { params }) });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
this.pending.delete(String(id));
|
|
160
|
+
return Promise.reject(error);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
write(message) {
|
|
165
|
+
const child = this.child;
|
|
166
|
+
if (!child || !child.stdin.writable)
|
|
167
|
+
throw new Error("Codex app-server stdin is unavailable");
|
|
168
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
169
|
+
}
|
|
170
|
+
handleLine(line) {
|
|
171
|
+
let message;
|
|
172
|
+
try {
|
|
173
|
+
message = JSON.parse(line);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
console.error(`[codex-app-server] ignored non-JSON output: ${redactSensitiveText(line, 500)}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!isRecord(message))
|
|
180
|
+
return;
|
|
181
|
+
if ((typeof message.id === "string" || typeof message.id === "number") && !message.method) {
|
|
182
|
+
this.handleResponse(message);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (typeof message.method !== "string")
|
|
186
|
+
return;
|
|
187
|
+
if (typeof message.id === "string" || typeof message.id === "number") {
|
|
188
|
+
void this.handleServerRequest({
|
|
189
|
+
id: message.id,
|
|
190
|
+
method: message.method,
|
|
191
|
+
...(message.params === undefined ? {} : { params: message.params }),
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
this.options.onNotification?.({
|
|
197
|
+
method: message.method,
|
|
198
|
+
...(message.params === undefined ? {} : { params: message.params }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
console.error(`[codex-app-server] notification handler failed method=${message.method}`, error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
handleResponse(response) {
|
|
206
|
+
const pending = this.pending.get(String(response.id));
|
|
207
|
+
if (!pending)
|
|
208
|
+
return;
|
|
209
|
+
this.pending.delete(String(response.id));
|
|
210
|
+
clearTimeout(pending.timeout);
|
|
211
|
+
if (response.error) {
|
|
212
|
+
pending.reject(new AppServerRpcError(response.error.message ?? `Codex app-server request failed: ${pending.method}`, response.error.code, response.error.data));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
pending.resolve(response.result);
|
|
216
|
+
}
|
|
217
|
+
async handleServerRequest(request) {
|
|
218
|
+
try {
|
|
219
|
+
if (!this.options.onRequest) {
|
|
220
|
+
throw new AppServerRpcError(`Unsupported server request: ${request.method}`, -32601);
|
|
221
|
+
}
|
|
222
|
+
const result = await this.options.onRequest(request);
|
|
223
|
+
this.write({ id: request.id, result });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const rpcError = error instanceof AppServerRpcError ? error : null;
|
|
227
|
+
try {
|
|
228
|
+
this.write({
|
|
229
|
+
id: request.id,
|
|
230
|
+
error: {
|
|
231
|
+
code: rpcError?.code ?? -32000,
|
|
232
|
+
message: safeErrorText(error),
|
|
233
|
+
...(rpcError?.data === undefined ? {} : { data: rpcError.data }),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (writeError) {
|
|
238
|
+
console.error(`[codex-app-server] unable to answer request method=${request.method}`, writeError);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
handleExit(child, code, signal, stderrTail) {
|
|
243
|
+
if (this.child !== child)
|
|
244
|
+
return;
|
|
245
|
+
this.child = null;
|
|
246
|
+
this.lines?.close();
|
|
247
|
+
this.lines = null;
|
|
248
|
+
this.health.ready = false;
|
|
249
|
+
delete this.health.pid;
|
|
250
|
+
this.health.lastExitAt = Date.now();
|
|
251
|
+
if (!this.closing)
|
|
252
|
+
this.health.restartCount += 1;
|
|
253
|
+
const detail = signal ? `signal ${signal}` : `code ${code ?? 1}`;
|
|
254
|
+
const suffix = stderrTail.trim()
|
|
255
|
+
? `: ${redactSensitiveText(stderrTail.trim().slice(-1_000))}`
|
|
256
|
+
: "";
|
|
257
|
+
const error = new Error(`Codex app-server exited with ${detail}${suffix}`);
|
|
258
|
+
this.health.lastError = error.message;
|
|
259
|
+
rejectPending(this.pending, error);
|
|
260
|
+
if (!this.closing)
|
|
261
|
+
this.options.onExit?.(error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function resolveBundledCodexCommand(extraArgs = []) {
|
|
265
|
+
const require = createRequire(import.meta.url);
|
|
266
|
+
const packageJson = require.resolve("@openai/codex/package.json");
|
|
267
|
+
return {
|
|
268
|
+
command: process.execPath,
|
|
269
|
+
args: [path.join(path.dirname(packageJson), "bin", "codex.js"), "app-server", ...extraArgs],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function rejectPending(pending, error) {
|
|
273
|
+
for (const call of pending.values()) {
|
|
274
|
+
clearTimeout(call.timeout);
|
|
275
|
+
call.reject(error);
|
|
276
|
+
}
|
|
277
|
+
pending.clear();
|
|
278
|
+
}
|
|
279
|
+
function isRecord(value) {
|
|
280
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
281
|
+
}
|