@evandu/super-pi 0.1.132 → 0.1.133

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.
Files changed (46) hide show
  1. package/dist/.super-pi-check-inputs.json +4 -0
  2. package/dist/client/.super-pi-client-sources.json +5 -5
  3. package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-CZZfGD0U.js} +2 -2
  4. package/dist/client/assets/{dist-8pdzh6h1.js → dist-0FH-oFco.js} +1 -1
  5. package/dist/client/assets/{dist-Bg9zLfex.js → dist-4Pts9xPO.js} +1 -1
  6. package/dist/client/assets/{dist-Hj_dWgPr.js → dist-B5ruVtUG.js} +1 -1
  7. package/dist/client/assets/{dist-Cu75NyQ0.js → dist-BJinarhP.js} +1 -1
  8. package/dist/client/assets/{dist-CMscy2Nn.js → dist-BffE00vI.js} +1 -1
  9. package/dist/client/assets/{dist-CoszOk5k.js → dist-BtTFCNVQ.js} +1 -1
  10. package/dist/client/assets/{dist-DvHghuGd.js → dist-C2nuBCP4.js} +1 -1
  11. package/dist/client/assets/{dist-BAJCrbnX.js → dist-CApAttfj.js} +1 -1
  12. package/dist/client/assets/{dist-CPLCQDqy.js → dist-CE5BnwJa.js} +1 -1
  13. package/dist/client/assets/{dist-BRjGckmQ.js → dist-CFPXj4ws.js} +1 -1
  14. package/dist/client/assets/{dist-BjZUYxLd.js → dist-CUKaDTyO.js} +1 -1
  15. package/dist/client/assets/{dist-vsMBLoxV.js → dist-CYD2SqmG.js} +1 -1
  16. package/dist/client/assets/{dist-B8OZ-n_C.js → dist-CxsunljU.js} +1 -1
  17. package/dist/client/assets/{dist-D_Zdnhm8.js → dist-D4x1XTQb.js} +1 -1
  18. package/dist/client/assets/{dist-Zhdkfvng.js → dist-DHqqpxo4.js} +1 -1
  19. package/dist/client/assets/{dist-DNZMXYNq.js → dist-DOYWHMIq.js} +1 -1
  20. package/dist/client/assets/{dist-BJi5HmH4.js → dist-DQf5nNxa.js} +1 -1
  21. package/dist/client/assets/{dist-CgmISAMr.js → dist-DbbBgSwL.js} +1 -1
  22. package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-LFfokYJs.js} +1 -1
  23. package/dist/client/assets/{dist-C6iAHGvb.js → dist-aL2NGji_.js} +1 -1
  24. package/dist/client/assets/{dist--igDAzRS.js → dist-azDN8a65.js} +1 -1
  25. package/dist/client/assets/{dist-60usx__o.js → dist-fYp8Xwrx.js} +1 -1
  26. package/dist/client/assets/index-YEN4ysHF.js +100 -0
  27. package/dist/client/index.html +1 -1
  28. package/dist/server/agents.js +21 -1
  29. package/dist/server/application/feishu-service.d.ts +57 -10
  30. package/dist/server/application/feishu-service.js +118 -24
  31. package/dist/server/application/run-service.js +15 -1
  32. package/dist/server/feishu-cli.d.ts +18 -2
  33. package/dist/server/feishu-cli.js +46 -12
  34. package/dist/server/feishu-config.d.ts +68 -0
  35. package/dist/server/feishu-config.js +84 -0
  36. package/dist/server/feishu.d.ts +187 -0
  37. package/dist/server/feishu.js +695 -0
  38. package/dist/server/http-server.js +20 -0
  39. package/dist/server/persistence/sqlite-state-repository.js +18 -0
  40. package/dist/server/pi-session-events.js +10 -2
  41. package/dist/server/pi-session-pool.d.ts +33 -1
  42. package/dist/server/pi-session-pool.js +20 -2
  43. package/dist/server/runtime.d.ts +26 -1
  44. package/dist/server/runtime.js +52 -9
  45. package/package.json +10 -1
  46. package/dist/client/assets/index-ChyemF2I.js +0 -100
@@ -8,7 +8,7 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-ChyemF2I.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-YEN4ysHF.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-D3MEG8uH.css">
13
13
  </head>
14
14
  <body>
@@ -447,7 +447,27 @@ export async function readAgents(directory, options = {}) {
447
447
  throw error;
448
448
  }
449
449
  for (const file of entries.filter((name) => name.toLowerCase().endsWith(".md")).sort()) {
450
- const source = await readFile(join(dir, file), "utf8");
450
+ // A FILE THAT CANNOT BE READ IS THE SAME KIND OF PROBLEM AS ONE THAT CANNOT BE PARSED.
451
+ //
452
+ // The function's own reason for existing says it plainly: "one typo in one agent must not take
453
+ // down the picker and hide every other agent with it". A file that cannot be READ is at least as
454
+ // ordinary as that - on Windows "another program has this file open" is a Tuesday, not an
455
+ // emergency - and it used to be the one unguarded step in this loop: the parse failure below is
456
+ // collected as a problem, while the read failure was thrown, so a single locked file emptied the
457
+ // whole directory. Four callers ate that: the main agent's own definition (the turn could not
458
+ // start), the roster handed to sub-agents on every turn, the roster used when dispatching one,
459
+ // and the picker (which came back with an empty agent list).
460
+ let source;
461
+ try {
462
+ source = await readFile(join(dir, file), "utf8");
463
+ }
464
+ catch (error) {
465
+ const fileId = file.replace(/\.md$/i, "");
466
+ if (!seenIds.has(fileId)) {
467
+ problems.push({ file, message: `读取失败:${error instanceof Error ? error.message : String(error)}` });
468
+ }
469
+ continue;
470
+ }
451
471
  const parsed = parseDefinition(file, source);
452
472
  if ("message" in parsed) {
453
473
  const fileId = file.replace(/\.md$/i, "");
@@ -23,8 +23,31 @@ export type FeishuStatus = {
23
23
  identity?: "user" | "bot";
24
24
  /** Whether the agent can read and write documents right now. */
25
25
  connected: boolean;
26
+ /**
27
+ * WHICH STEP OF THE GUIDE THE PERSON IS ON.
28
+ *
29
+ * 1 = install the CLI, 2 = connect the account, 3 = ready. The panel draws the three steps and
30
+ * marks this one, so "what do I do now" is answered by where the cursor is rather than by a
31
+ * sentence the person has to interpret.
32
+ */
33
+ step: 1 | 2 | 3;
26
34
  /** What to do next, in one sentence. */
27
35
  message: string;
36
+ /** The CLI's own words, when it had something to say about the situation. */
37
+ detail?: string;
38
+ /** The CLI's own hint, which usually names the exact command to run. */
39
+ hint?: string;
40
+ /** Whether the check actually reached Feishu. */
41
+ verified?: boolean;
42
+ /**
43
+ * WHETHER THE APPLICATION ITSELF HAS TO BE REBUILT.
44
+ *
45
+ * Distinct from "step 2": a person whose application is fine but who has not logged in still
46
+ * needs step 2, while a person whose credential was refused needs a NEW application. Telling
47
+ * those two apart is what keeps a transient network failure from being answered by "let me
48
+ * create you another application".
49
+ */
50
+ rebuild?: boolean;
28
51
  };
29
52
  /** The outcome of starting the connect flow. */
30
53
  export type FeishuConnectResult = {
@@ -50,35 +73,59 @@ export declare class FeishuService {
50
73
  private readonly cli;
51
74
  /** The device code of a login that is waiting for the person to authorize it. */
52
75
  private pendingDeviceCode?;
76
+ /** Whether an application registration is running and waiting for the browser confirmation. */
77
+ private pendingAppCreation;
53
78
  constructor(cli?: FeishuCli);
54
79
  /**
55
80
  * WHAT THE CLI CAN DO RIGHT NOW.
56
81
  *
57
- * The three states a person can be in, and what each one means for the agent:
82
+ * The states a person can be in, and what each one means for the agent:
58
83
  *
59
84
  * - The CLI is not installed: nothing works, and the fix is one command.
60
85
  * - The CLI has no application: `config init --new` creates one through a browser confirmation,
61
86
  * so the developer console is never visited.
62
- * - The CLI has an application but no person logged in: the agent can still act as the
63
- * application, but it cannot reach that person's own documents until they authorize it.
87
+ * - The application exists but its credential is no longer accepted: the CLI says
88
+ * `verify_failed` and names the reason, and the fix is a new application - which is the same
89
+ * browser confirmation, so it stays one button.
90
+ * - The application works but no person is logged in: the agent can act as the application, but
91
+ * it cannot reach that person's own documents until they authorize it.
64
92
  */
65
93
  status(): Promise<FeishuStatus>;
94
+ /**
95
+ * CHECKS THE CONNECTION, and says whether it actually works.
96
+ *
97
+ * The status already verifies; this is the same answer shaped for a button, so that a person can
98
+ * ask "it still doesn't work, why?" and get the CLI's own verdict rather than a guess.
99
+ */
100
+ verify(): Promise<{
101
+ ok: boolean;
102
+ message: string;
103
+ detail?: string;
104
+ hint?: string;
105
+ }>;
66
106
  /**
67
107
  * STARTS THE CONNECT FLOW.
68
108
  *
69
109
  * Two steps, and which one runs is decided by what the CLI already has:
70
110
  *
71
- * - No application: `config init --new` registers one through a browser confirmation. It BLOCKS
72
- * until the person confirms, so it runs in the background and the URL is taken from its output.
73
- * - An application but no person: `auth login --no-wait --json` asks for a device code and hands
74
- * back the URL to open. The code is kept here until the person says they are done.
111
+ * - No application, or one whose credential no longer works: `config init --new` registers a
112
+ * fresh application through a browser confirmation. It BLOCKS until the person confirms, so it
113
+ * runs in the background and the URL is taken from its output. The broken case goes here too,
114
+ * because a new application is the one repair that needs no secret to be typed into a form.
115
+ * - A working application but no person: `auth login --no-wait --json` asks for a device code
116
+ * and hands back the URL to open. The code is kept here until the person says they are done.
75
117
  */
76
118
  connect(): Promise<FeishuConnectResult>;
77
119
  /**
78
- * FINISHES THE LOGIN, once the person says they authorized it.
120
+ * FINISHES THE FLOW, once the person says they are done.
121
+ *
122
+ * TWO KINDS OF "DONE", and the difference is what the CLI is waiting for:
79
123
  *
80
- * The device code is handed back to the CLI, which completes the exchange and stores the token
81
- * in the keychain. Super Pi never sees the token itself.
124
+ * - An application is being registered: `config init --new` finishes by itself once the person
125
+ * confirms in the browser, so there is nothing to hand back. The status is re-read and the
126
+ * answer is whether the application showed up.
127
+ * - A login is being authorized: the device code is handed back to the CLI, which completes the
128
+ * exchange and stores the token in the keychain. Super Pi never sees the token itself.
82
129
  */
83
130
  completeConnect(): Promise<FeishuConnectResult>;
84
131
  /**
@@ -17,19 +17,24 @@ export class FeishuService {
17
17
  cli;
18
18
  /** The device code of a login that is waiting for the person to authorize it. */
19
19
  pendingDeviceCode;
20
+ /** Whether an application registration is running and waiting for the browser confirmation. */
21
+ pendingAppCreation = false;
20
22
  constructor(cli = new FeishuCli()) {
21
23
  this.cli = cli;
22
24
  }
23
25
  /**
24
26
  * WHAT THE CLI CAN DO RIGHT NOW.
25
27
  *
26
- * The three states a person can be in, and what each one means for the agent:
28
+ * The states a person can be in, and what each one means for the agent:
27
29
  *
28
30
  * - The CLI is not installed: nothing works, and the fix is one command.
29
31
  * - The CLI has no application: `config init --new` creates one through a browser confirmation,
30
32
  * so the developer console is never visited.
31
- * - The CLI has an application but no person logged in: the agent can still act as the
32
- * application, but it cannot reach that person's own documents until they authorize it.
33
+ * - The application exists but its credential is no longer accepted: the CLI says
34
+ * `verify_failed` and names the reason, and the fix is a new application - which is the same
35
+ * browser confirmation, so it stays one button.
36
+ * - The application works but no person is logged in: the agent can act as the application, but
37
+ * it cannot reach that person's own documents until they authorize it.
33
38
  */
34
39
  async status() {
35
40
  const status = await this.cli.status();
@@ -39,59 +44,121 @@ export class FeishuService {
39
44
  userReady: false,
40
45
  botReady: false,
41
46
  connected: false,
42
- message: "没有检测到官方飞书 CLI(lark-cli)。请先在终端运行 npm i -g @larksuite/cli 装上它,再回到这里点「连接飞书账号」。如果已经装了但 Super Pi 找不到(两边的 PATH 不一样),用环境变量 SUPER_PI_FEISHU_CLI 指向它的完整路径再重启 Super Pi。",
47
+ step: 1,
48
+ message: "还没有安装官方飞书 CLI。在终端里跑一次 npm i -g @larksuite/cli 就行,装完回到这里点「连接飞书账号」。",
49
+ ...(status.hint ? { hint: status.hint } : {}),
43
50
  };
44
51
  }
52
+ const common = {
53
+ installed: true,
54
+ ...(status.version ? { version: status.version } : {}),
55
+ ...(status.appId ? { appId: status.appId } : {}),
56
+ ...(status.verified !== undefined ? { verified: status.verified } : {}),
57
+ ...(status.note ? { detail: status.note } : {}),
58
+ };
45
59
  if (!status.appId) {
46
60
  return {
47
- installed: true,
48
- ...(status.version ? { version: status.version } : {}),
61
+ ...common,
62
+ userReady: false,
63
+ botReady: false,
64
+ connected: false,
65
+ step: 2,
66
+ message: "CLI 已就绪,还差一个应用。点「连接飞书账号」,浏览器里确认一下就有了——不用去开发者后台注册。",
67
+ };
68
+ }
69
+ // A CREDENTIAL THE CLI KNOWS IS BROKEN is the case a plain status would have hidden: the local
70
+ // file still holds an app, so the panel would have said "已配置", and every document call
71
+ // would have failed. The CLI's own verdict is the one that can be acted on.
72
+ const broken = status.botStatus === "verify_failed" || (status.verified === true && !status.botReady && !status.userReady);
73
+ if (broken) {
74
+ return {
75
+ ...common,
49
76
  userReady: false,
50
77
  botReady: false,
51
78
  connected: false,
52
- message: "飞书 CLI 已就绪,但还没有应用。点「连接飞书账号」,它会给你一个浏览器链接,确认之后应用就建好了——不用去开发者后台注册。",
79
+ step: 2,
80
+ rebuild: true,
81
+ message: `应用的凭据已经失效:${trimSentence(status.botMessage ?? "飞书拒绝了这次验证")}。点「连接飞书账号」会重新走一遍确认流程、建一个可用的应用。`,
82
+ ...(status.hint ? { hint: status.hint } : {}),
83
+ };
84
+ }
85
+ if (status.verified === false) {
86
+ return {
87
+ ...common,
88
+ userReady: false,
89
+ botReady: false,
90
+ connected: false,
91
+ step: 2,
92
+ message: "没能连上飞书(本地配置读到了,但验证请求没出去)。检查一下网络,然后点「测试连接」重试。",
93
+ ...(status.hint ? { hint: status.hint } : {}),
53
94
  };
54
95
  }
55
96
  if (!status.userReady) {
56
97
  return {
57
- installed: true,
58
- ...(status.version ? { version: status.version } : {}),
59
- appId: status.appId,
98
+ ...common,
60
99
  userReady: false,
61
100
  botReady: status.botReady,
62
101
  connected: false,
102
+ step: 2,
63
103
  ...(status.identity ? { identity: status.identity } : {}),
64
- message: "应用已配置,但还没有登录你的账号。点「连接飞书账号」用浏览器授权,授权之后 Agent 就能以你的身份读写你自己的文档,不用逐篇加协作者。",
104
+ message: "应用已经能用,但还没登录你的账号。点「连接飞书账号」在浏览器里授权一下,授权之后 Agent 就能以你的身份读写你自己的文档,不用逐篇加协作者。",
105
+ ...(status.hint ? { hint: status.hint } : {}),
65
106
  };
66
107
  }
67
108
  return {
68
- installed: true,
69
- ...(status.version ? { version: status.version } : {}),
70
- appId: status.appId,
109
+ ...common,
71
110
  userReady: true,
72
111
  botReady: status.botReady,
73
112
  connected: true,
74
113
  identity: status.identity ?? "user",
114
+ step: 3,
75
115
  message: "已连接:Agent 会以你的身份读写飞书云文档。",
76
116
  };
77
117
  }
118
+ /**
119
+ * CHECKS THE CONNECTION, and says whether it actually works.
120
+ *
121
+ * The status already verifies; this is the same answer shaped for a button, so that a person can
122
+ * ask "it still doesn't work, why?" and get the CLI's own verdict rather than a guess.
123
+ */
124
+ async verify() {
125
+ const status = await this.status();
126
+ if (status.connected) {
127
+ return {
128
+ ok: true,
129
+ message: status.message,
130
+ ...(status.detail ? { detail: status.detail } : {}),
131
+ };
132
+ }
133
+ return {
134
+ ok: false,
135
+ message: status.message,
136
+ ...(status.detail ? { detail: status.detail } : {}),
137
+ ...(status.hint ? { hint: status.hint } : {}),
138
+ };
139
+ }
78
140
  /**
79
141
  * STARTS THE CONNECT FLOW.
80
142
  *
81
143
  * Two steps, and which one runs is decided by what the CLI already has:
82
144
  *
83
- * - No application: `config init --new` registers one through a browser confirmation. It BLOCKS
84
- * until the person confirms, so it runs in the background and the URL is taken from its output.
85
- * - An application but no person: `auth login --no-wait --json` asks for a device code and hands
86
- * back the URL to open. The code is kept here until the person says they are done.
145
+ * - No application, or one whose credential no longer works: `config init --new` registers a
146
+ * fresh application through a browser confirmation. It BLOCKS until the person confirms, so it
147
+ * runs in the background and the URL is taken from its output. The broken case goes here too,
148
+ * because a new application is the one repair that needs no secret to be typed into a form.
149
+ * - A working application but no person: `auth login --no-wait --json` asks for a device code
150
+ * and hands back the URL to open. The code is kept here until the person says they are done.
87
151
  */
88
152
  async connect() {
89
153
  const status = await this.status();
90
154
  if (!status.installed) {
91
155
  return { ok: false, message: status.message };
92
156
  }
93
- if (!status.appId) {
157
+ // Step 2 is "connect the account"; the application part of it is missing or broken when the
158
+ // person has no application OR when the one they have was refused by Feishu.
159
+ if (!status.appId || status.rebuild) {
94
160
  const verificationUri = await this.cli.startAppCreation();
161
+ this.pendingAppCreation = true;
95
162
  return {
96
163
  ok: true,
97
164
  step: "app",
@@ -119,12 +186,31 @@ export class FeishuService {
119
186
  }
120
187
  }
121
188
  /**
122
- * FINISHES THE LOGIN, once the person says they authorized it.
189
+ * FINISHES THE FLOW, once the person says they are done.
123
190
  *
124
- * The device code is handed back to the CLI, which completes the exchange and stores the token
125
- * in the keychain. Super Pi never sees the token itself.
191
+ * TWO KINDS OF "DONE", and the difference is what the CLI is waiting for:
192
+ *
193
+ * - An application is being registered: `config init --new` finishes by itself once the person
194
+ * confirms in the browser, so there is nothing to hand back. The status is re-read and the
195
+ * answer is whether the application showed up.
196
+ * - A login is being authorized: the device code is handed back to the CLI, which completes the
197
+ * exchange and stores the token in the keychain. Super Pi never sees the token itself.
126
198
  */
127
199
  async completeConnect() {
200
+ if (this.pendingAppCreation) {
201
+ this.pendingAppCreation = false;
202
+ const status = await this.status();
203
+ if (status.appId) {
204
+ return {
205
+ ok: true,
206
+ message: "应用建好了。接下来点「连接飞书账号」授权你的账号,Agent 就能以你的身份读写文档了。",
207
+ };
208
+ }
209
+ return {
210
+ ok: false,
211
+ message: "还没看到应用建好。请确认浏览器里的确认已经提交(有时要等 CLI 写完钥匙串),然后再点一次「我已完成」。",
212
+ };
213
+ }
128
214
  if (!this.pendingDeviceCode) {
129
215
  return { ok: false, message: "没有待完成的登录。点「连接飞书账号」重新开始。" };
130
216
  }
@@ -235,8 +321,16 @@ export class FeishuService {
235
321
  throw new Error(`未知的飞书工具操作:${action}`);
236
322
  }
237
323
  }
238
- /** The message of a failure, whatever shape the failure has. */
239
- function messageOf(error) {
324
+ /**
325
+ * A SENTENCE WITHOUT ITS OWN FULL STOP.
326
+ *
327
+ * The CLI writes English sentences that end in a period, and this service writes Chinese ones that
328
+ * end in 。 — so a message pasted in verbatim reads "invalid.。点「重新连接」".
329
+ */
330
+ function trimSentence(text) {
331
+ return text.trim().replace(/[.\s]+$/, "");
332
+ }
333
+ /** The message of a failure, whatever shape the failure has. */ function messageOf(error) {
240
334
  if (error instanceof FeishuCliError)
241
335
  return error.message;
242
336
  if (error instanceof Error)
@@ -1132,9 +1132,17 @@ export class RunService {
1132
1132
  // `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
1133
1133
  // having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
1134
1134
  // the delta, so these two halves together are the whole lifecycle.
1135
- onThinking: (delta) => {
1135
+ onThinking: async (delta) => {
1136
1136
  if (!delta)
1137
1137
  return;
1138
+ // THINKING IS THE MODEL PRODUCING SOMETHING, SO THE TURN IS RUNNING. The contract above
1139
+ // says a turn moves from `waiting` to `running` once the model has produced ANY signal -
1140
+ // "regardless of shape" - and a thinking delta is the same signal as a text delta: it is Pi
1141
+ // yielding data back. Only two shapes promoted the turn (text and a tool-call notice), so a
1142
+ // model that thinks before it answers sat in `waiting` for the whole of its reasoning: the
1143
+ // panel said 「等待 LLM 返回」 while the model was plainly working, and - because the stop
1144
+ // button appears while a turn is in flight - the reader had no way to stop it for exactly
1145
+ // as long as the thinking lasted.
1138
1146
  const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
1139
1147
  if (newBlock) {
1140
1148
  thinkingBlock++;
@@ -1145,6 +1153,12 @@ export class RunService {
1145
1153
  this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
1146
1154
  thought += delta.length;
1147
1155
  this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
1156
+ // THE PROMOTION COMES AFTER THE DELTA IS IN THE STORE, so the state snapshot it broadcasts
1157
+ // carries the same thinking the client is about to receive as a live event. Promoting first
1158
+ // broadcast a snapshot WITHOUT this delta, and the client - which prefers the live body only
1159
+ // while the conversation is running - took that stale snapshot, lost the thinking block's
1160
+ // boundary, and dropped a tool call out of the block it belonged to.
1161
+ await promoteToRunning();
1148
1162
  this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
1149
1163
  scheduleCheckpoint();
1150
1164
  },
@@ -64,6 +64,16 @@ export type FeishuCliStatus = {
64
64
  identity?: FeishuIdentity;
65
65
  /** Feishu's own "what to do about it", when the CLI had something to say. */
66
66
  hint?: string;
67
+ /** The CLI's own summary of the situation, when it has one. */
68
+ note?: string;
69
+ /** The state of each identity, as the CLI words it: ready, missing, verify_failed, ... */
70
+ botStatus?: string;
71
+ userStatus?: string;
72
+ /** What the CLI says about each identity, in its own words. */
73
+ userMessage?: string;
74
+ botMessage?: string;
75
+ /** Whether the check actually reached Feishu, rather than only reading the local credential. */
76
+ verified?: boolean;
67
77
  };
68
78
  /** The outcome of a CLI call, in the shape this product speaks in. */
69
79
  export declare class FeishuCliError extends Error {
@@ -139,8 +149,14 @@ export declare class FeishuCli {
139
149
  /**
140
150
  * WHAT THE CLI KNOWS ABOUT ITSELF.
141
151
  *
142
- * `auth status` is the one command that answers without touching the network unless asked to
143
- * verify, so it is what the panel polls.
152
+ * THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
153
+ * CLI only reads its local credential and reports the application as "ready" — which is what it
154
+ * said on a machine whose App Secret had expired, and which would have left the panel claiming
155
+ * everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
156
+ * the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
157
+ *
158
+ * When the verification cannot reach Feishu at all (no network), the plain status is read
159
+ * instead, and `verified: false` says so rather than pretending.
144
160
  */
145
161
  status(): Promise<FeishuCliStatus>;
146
162
  /**
@@ -198,38 +198,72 @@ export class FeishuCli {
198
198
  /**
199
199
  * WHAT THE CLI KNOWS ABOUT ITSELF.
200
200
  *
201
- * `auth status` is the one command that answers without touching the network unless asked to
202
- * verify, so it is what the panel polls.
201
+ * THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
202
+ * CLI only reads its local credential and reports the application as "ready" — which is what it
203
+ * said on a machine whose App Secret had expired, and which would have left the panel claiming
204
+ * everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
205
+ * the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
206
+ *
207
+ * When the verification cannot reach Feishu at all (no network), the plain status is read
208
+ * instead, and `verified: false` says so rather than pretending.
203
209
  */
204
210
  async status() {
205
211
  const version = await this.version();
206
212
  if (!version)
207
213
  return { installed: false, userReady: false, botReady: false };
214
+ const read = async (args) => this.run(args, { timeoutMs: 30_000 });
208
215
  let envelope;
216
+ let verified = true;
209
217
  try {
210
- envelope = await this.run(["auth", "status", "--json"], { timeoutMs: 30_000 });
218
+ envelope = await read(["auth", "status", "--verify", "--json"]);
219
+ // The CLI answers a failed verification with an error envelope. That is not the same as a
220
+ // credential that Feishu refused - it means the question was never answered - so the local
221
+ // status is read instead and the panel is told that Feishu was never asked.
222
+ if (envelope.ok === false) {
223
+ envelope = await read(["auth", "status", "--json"]);
224
+ verified = false;
225
+ }
211
226
  }
212
227
  catch (error) {
213
- return {
214
- installed: true,
215
- version,
216
- userReady: false,
217
- botReady: false,
218
- ...(error instanceof FeishuCliError ? { hint: error.message } : {}),
219
- };
228
+ try {
229
+ envelope = await read(["auth", "status", "--json"]);
230
+ verified = false;
231
+ }
232
+ catch {
233
+ return {
234
+ installed: true,
235
+ version,
236
+ userReady: false,
237
+ botReady: false,
238
+ ...(error instanceof FeishuCliError ? { hint: error.message } : {}),
239
+ };
240
+ }
220
241
  }
221
242
  const identities = pick(envelope.data ?? envelope, "identities");
222
243
  const user = identities?.user;
223
244
  const bot = identities?.bot;
224
245
  const identity = asString(envelope.identity) ?? pick(envelope.data ?? envelope, "identity");
246
+ const appId = asString(pick(envelope.data ?? envelope, "appId", "app_id"));
247
+ const note = asString(pick(envelope.data ?? envelope, "note"));
248
+ // THE HINT OF THE IDENTITY THAT IS FAILING. Both identities carry a hint, and the wrong one
249
+ // sends the person to the wrong command: the application's hint says "log in" when the real
250
+ // problem is that the credential was refused, and the user's hint says "check the credential"
251
+ // when all that is missing is a login.
252
+ const failing = bot?.available !== true ? bot : user;
225
253
  return {
226
254
  installed: true,
227
255
  version,
228
- ...(asString(pick(envelope.data ?? envelope, "appId", "app_id")) ? { appId: asString(pick(envelope.data ?? envelope, "appId", "app_id")) } : {}),
256
+ ...(appId ? { appId } : {}),
229
257
  userReady: user?.available === true,
230
258
  botReady: bot?.available === true,
231
259
  ...(identity === "user" || identity === "bot" ? { identity } : {}),
232
- ...(asString(user?.hint) ?? asString(bot?.hint) ? { hint: (asString(user?.hint) ?? asString(bot?.hint)) } : {}),
260
+ ...(asString(user?.status) ? { userStatus: asString(user?.status) } : {}),
261
+ ...(asString(bot?.status) ? { botStatus: asString(bot?.status) } : {}),
262
+ ...(asString(user?.message) ? { userMessage: asString(user?.message) } : {}),
263
+ ...(asString(bot?.message) ? { botMessage: asString(bot?.message) } : {}),
264
+ ...(asString(failing?.hint) ? { hint: asString(failing?.hint) } : {}),
265
+ ...(note ? { note } : {}),
266
+ verified,
233
267
  };
234
268
  }
235
269
  /**
@@ -0,0 +1,68 @@
1
+ import type { FeishuDomain } from "./feishu.js";
2
+ /**
3
+ * THE FEISHU CREDENTIALS, and where they live.
4
+ *
5
+ * WHY A FILE IN THE DATA DIRECTORY AND NOT A FIELD IN THE APP STATE. The App State is broadcast to
6
+ * every browser that connects, and it is written to disk in a form that is meant to be readable.
7
+ * A secret does not belong there - the same rule the provider registry follows, where the API key
8
+ * lives in `<dataDir>/providers.json` with restrictive permissions and the browser is told only
9
+ * that a key exists.
10
+ *
11
+ * WHY THE ENVIRONMENT OVERRIDES THE FILE. A deployment that provisions the credential through the
12
+ * environment should not have to write a file as well, and the environment is the harder thing to
13
+ * override accidentally. The file is the fallback, not the other way round.
14
+ *
15
+ * WHY THERE IS ONE SET OF CREDENTIALS FOR THE WHOLE SERVER. Feishu credentials belong to an
16
+ * application inside one enterprise, and Super Pi runs on one person's machine. A per-workspace
17
+ * credential would be a second enterprise's application, and the user of that workspace would have
18
+ * to be handed a secret they never asked for.
19
+ */
20
+ export type FeishuConfig = {
21
+ /** The application's App ID. */
22
+ appId?: string;
23
+ /** The application's App Secret. Never leaves the server. */
24
+ appSecret?: string;
25
+ /** Which Feishu the calls go to. */
26
+ domain?: FeishuDomain;
27
+ };
28
+ /** Where the credentials come from, for the panel to show. */
29
+ export type FeishuConfigSource = "environment" | "file" | "none";
30
+ export declare class FeishuConfigStore {
31
+ private readonly file;
32
+ constructor(file: string);
33
+ /**
34
+ * The credentials in force, and where they came from.
35
+ *
36
+ * The environment wins over the file, and the file is read only when the environment has not
37
+ * said anything - so a machine that provisions the credential through the environment never
38
+ * touches the file at all.
39
+ */
40
+ read(): Promise<{
41
+ config: FeishuConfig;
42
+ source: FeishuConfigSource;
43
+ }>;
44
+ /**
45
+ * The file's own contents, and only its own contents.
46
+ *
47
+ * A file that cannot be read is a file that is not there, and a file that cannot be parsed is a
48
+ * file this server did not write. Both are reported rather than thrown: a broken credential
49
+ * file must not stop the server from starting.
50
+ */
51
+ private readFile;
52
+ /**
53
+ * Writes the credentials.
54
+ *
55
+ * The write is atomic (a temporary file and a rename) so a crash cannot leave half a JSON file
56
+ * behind, and the file's mode is 0600 so that a secret is not readable by every account on the
57
+ * machine. On Windows the mode is not enforced the same way, and there is nothing this code can
58
+ * do about that.
59
+ */
60
+ write(config: FeishuConfig): Promise<void>;
61
+ }
62
+ /**
63
+ * The domain a name means, defaulting to Feishu.
64
+ *
65
+ * A name this does not recognise is Feishu rather than an error: the domain is a convenience for
66
+ * Lark users, and refusing to start over a typo in it would be worse than trying Feishu.
67
+ */
68
+ export declare function domainOf(value: string | undefined): FeishuDomain;