@cydm/pie 2.7.14 → 2.7.15

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.
@@ -5,168 +5,17 @@ import {
5
5
  } from "../../../chunks/chunk-F7EBZTZO.js";
6
6
  import "../../../chunks/chunk-LZQXQOPJ.js";
7
7
 
8
- // builtin/extensions/im-forward/index.ts
9
- import { spawn as spawn2 } from "node:child_process";
8
+ // builtin/extensions/im-forward/config.ts
10
9
  import * as fs from "node:fs";
11
- import * as os from "node:os";
12
10
  import * as path from "node:path";
11
+ import * as os from "node:os";
12
+ import { randomUUID } from "node:crypto";
13
13
 
14
- // builtin/extensions/im-forward/lark-cli.ts
15
- import { spawn } from "node:child_process";
16
- async function runLarkCli(binary, args, timeoutMs = 3e4, cwd, options = {}) {
17
- return new Promise((resolve) => {
18
- let child;
19
- try {
20
- child = (options.spawn ?? spawn)(binary, args, {
21
- stdio: ["ignore", "pipe", "pipe"],
22
- detached: process.platform !== "win32",
23
- ...cwd ? { cwd } : {}
24
- });
25
- } catch (error) {
26
- resolve({ stdout: "", error: `spawn failed: ${error instanceof Error ? error.message : String(error)}` });
27
- return;
28
- }
29
- let stdout = "";
30
- let stderr = "";
31
- let settled = false;
32
- let timedOut = false;
33
- let killTimer;
34
- let closeTimer;
35
- const finish = (result) => {
36
- if (settled) return;
37
- settled = true;
38
- clearTimeout(timer);
39
- if (killTimer) clearTimeout(killTimer);
40
- if (closeTimer) clearTimeout(closeTimer);
41
- resolve(result);
42
- };
43
- const stopTree = (signal) => {
44
- try {
45
- if (process.platform === "win32" && child.pid) {
46
- const killer = spawn("taskkill", ["/F", "/T", "/PID", String(child.pid)], { stdio: "ignore" });
47
- killer.on("error", () => {
48
- child.kill(signal);
49
- });
50
- killer.unref();
51
- } else if (child.pid) process.kill(-child.pid, signal);
52
- else child.kill(signal);
53
- } catch {
54
- try {
55
- child.kill(signal);
56
- } catch {
57
- }
58
- }
59
- };
60
- const timeoutResult = () => ({ stdout, error: `timeout after ${timeoutMs}ms` });
61
- const timer = setTimeout(() => {
62
- timedOut = true;
63
- stopTree("SIGTERM");
64
- if (settled) return;
65
- killTimer = setTimeout(() => {
66
- stopTree("SIGKILL");
67
- if (settled) return;
68
- closeTimer = setTimeout(() => {
69
- child.stdout?.destroy();
70
- child.stderr?.destroy();
71
- finish(timeoutResult());
72
- }, options.closeGraceMs ?? 1e3);
73
- }, options.killGraceMs ?? 1e3);
74
- }, timeoutMs);
75
- child.stdout?.on("data", (chunk) => {
76
- if (!settled) stdout += chunk.toString("utf8");
77
- });
78
- child.stderr?.on("data", (chunk) => {
79
- if (!settled) stderr += chunk.toString("utf8");
80
- });
81
- child.on("error", (error) => finish({ stdout, error: `spawn error: ${error.message}` }));
82
- child.on("close", (code) => {
83
- if (timedOut) finish(timeoutResult());
84
- else if (code === 0 && stdout.trim()) finish({ stdout });
85
- else finish({ stdout, error: `exit ${code}${stderr ? `: ${stderr.slice(-300).trim()}` : ""}` });
86
- });
87
- });
88
- }
89
-
90
- // builtin/extensions/im-forward/index.ts
91
- var CLI_CALL_TIMEOUT_MS = 3e4;
92
- var ROUTE_TTL_MS = 24 * 60 * 60 * 1e3;
93
- var MAX_ROUTES = 500;
94
- var MAX_SEEN_EVENTS = 1e3;
95
- var MAX_BODY_CHARS = 4e3;
96
- var REPLY_CHAIN_MAX_DEPTH = 5;
14
+ // builtin/extensions/im-forward/helpers.ts
97
15
  var MAX_INPUT_HEADER_CHARS = 200;
98
- var RESTART_BACKOFF_MIN_MS = 5e3;
99
- var RESTART_BACKOFF_MAX_MS = 6e4;
100
- var SEND_FAILURE_NOTIFY_THROTTLE_MS = 5 * 60 * 1e3;
101
- var MAX_INBOUND_RETRIES = 3;
102
- var DEFAULT_INBOUND_RETRY_BACKOFF_MS = 5e3;
103
- var MAX_PENDING_IM_INPUTS = 50;
104
- var PENDING_IM_INPUT_TTL_MS = 60 * 60 * 1e3;
105
- var IM_INPUT_FALLBACK_CAPTION = "\u8BF7\u67E5\u770B\u8FD9\u4E9B\u56FE\u7247\u5E76\u5904\u7406\u3002";
106
- var IM_INPUT_PREFIX = "[Feishu]";
107
16
  function isRecord(value) {
108
17
  return typeof value === "object" && value !== null && !Array.isArray(value);
109
18
  }
110
- function readConfigLayer(filePath) {
111
- if (!fs.existsSync(filePath)) return { config: {} };
112
- try {
113
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
114
- if (!isRecord(parsed)) return { config: {}, problem: `${filePath}: root is not an object` };
115
- return { config: parsed };
116
- } catch (error) {
117
- return { config: {}, problem: `${filePath}: ${error instanceof Error ? error.message : String(error)}` };
118
- }
119
- }
120
- function resolveImForwardConfig(cwd, configDir) {
121
- const merged = {};
122
- const problems = [];
123
- let anyLayer = false;
124
- for (const layer of [path.join(configDir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")]) {
125
- if (fs.existsSync(layer)) anyLayer = true;
126
- const { config, problem } = readConfigLayer(layer);
127
- if (problem) problems.push(problem);
128
- Object.assign(merged, config);
129
- }
130
- if (!anyLayer) return { config: null, problems };
131
- const rawChatId = merged.chatId ?? merged.receiveId;
132
- const chatId = typeof rawChatId === "string" ? rawChatId.trim() : "";
133
- if (!chatId) {
134
- if (merged.enabled === true) problems.push('im-forward: "enabled": true requires "chatId" (oc_...)');
135
- return { config: null, problems };
136
- }
137
- if (!chatId.startsWith("oc_")) problems.push(`im-forward: chatId "${chatId}" does not look like a chat id (oc_...)`);
138
- const allowUsers = Array.isArray(merged.allowUsers) ? merged.allowUsers.filter((id) => typeof id === "string" && id.trim().length > 0) : void 0;
139
- const larkCli = typeof merged.larkCli === "string" && merged.larkCli.trim() ? merged.larkCli.trim() : void 0;
140
- const retryBackoffRaw = typeof merged.retryBackoffMs === "number" && Number.isFinite(merged.retryBackoffMs) ? merged.retryBackoffMs : NaN;
141
- const retryBackoffMs = Number.isNaN(retryBackoffRaw) ? void 0 : Math.min(6e4, Math.max(20, Math.round(retryBackoffRaw)));
142
- return { config: { enabled: merged.enabled === true, chatId, allowUsers: allowUsers?.length ? allowUsers : void 0, larkCli, retryBackoffMs }, problems };
143
- }
144
- function readLiveEnabled(cwd, configDir, cache) {
145
- const layers = [path.join(configDir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")];
146
- let enabled = false;
147
- let present = false;
148
- for (const layer of layers) {
149
- let mtime = 0;
150
- try {
151
- mtime = fs.statSync(layer).mtimeMs;
152
- } catch {
153
- continue;
154
- }
155
- present = true;
156
- if (cache && cache.path === layer && cache.mtime === mtime) {
157
- enabled = cache.enabled ?? false;
158
- continue;
159
- }
160
- const raw = readConfigLayer(layer).config;
161
- enabled = raw.enabled === true;
162
- if (cache) {
163
- cache.path = layer;
164
- cache.mtime = mtime;
165
- cache.enabled = enabled;
166
- }
167
- }
168
- return present ? enabled : false;
169
- }
170
19
  function assistantForwardText(message) {
171
20
  if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
172
21
  let text = "";
@@ -183,21 +32,83 @@ function sessionLabel(sessionId, sessionName) {
183
32
  if (sessionName && sessionName.trim()) return sessionName.trim();
184
33
  return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
185
34
  }
35
+ function escapeMarkdownInline(text) {
36
+ return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
37
+ }
186
38
  function inputHeaderLine(userInput) {
187
39
  if (!userInput) return null;
188
40
  const collapsed = userInput.replace(/\s+/g, " ").trim();
189
41
  if (!collapsed) return null;
190
- const clipped = collapsed.length > MAX_INPUT_HEADER_CHARS ? `${collapsed.slice(0, MAX_INPUT_HEADER_CHARS)}\u2026` : collapsed;
191
- return `[UserInput: ${clipped}]`;
42
+ let escaped = escapeMarkdownInline(collapsed);
43
+ if (escaped.length > MAX_INPUT_HEADER_CHARS) {
44
+ let clipped = stripDanglingHighSurrogate(escaped.slice(0, MAX_INPUT_HEADER_CHARS));
45
+ if (/(?:^|[^\\])(?:\\\\)*\\$/.test(clipped)) clipped = clipped.slice(0, -1);
46
+ escaped = clipped + "\u2026";
47
+ }
48
+ return `> [UserInput] ${escaped}`;
192
49
  }
193
50
  function composeForwardBody(sessionId, sessionName, userInput, text) {
194
- const lines = [`[Pie][Session: ${sessionLabel(sessionId, sessionName)}]`];
51
+ const lines = [`**[Pie] \xB7 ${sessionLabel(sessionId, sessionName)}**`];
195
52
  const input = inputHeaderLine(userInput);
196
53
  if (input) lines.push(input);
197
54
  lines.push("");
198
55
  lines.push(text);
199
56
  return lines.join("\n");
200
57
  }
58
+ var FEISHU_POST_REQUEST_LIMIT_BYTES = 3e4;
59
+ var MAX_BODY_BYTES = FEISHU_POST_REQUEST_LIMIT_BYTES - 6e3;
60
+ var MAX_BODY_UTF16_CHARS = 3e4;
61
+ var POST_PARAGRAPH_OVERHEAD_BYTES = 150;
62
+ var POST_LINE_OVERHEAD_BYTES = 12;
63
+ var POST_ENVELOPE_OVERHEAD_BYTES = 256;
64
+ var textEncoder = new TextEncoder();
65
+ function utf8Bytes(text) {
66
+ return textEncoder.encode(text).length;
67
+ }
68
+ function utf16LengthBudget(text, maxChars) {
69
+ if (text.length <= maxChars) return text;
70
+ return stripDanglingHighSurrogate(text.slice(0, maxChars));
71
+ }
72
+ function stripDanglingHighSurrogate(text) {
73
+ return /[\uD800-\uDBFF]$/.test(text) ? text.slice(0, -1) : text;
74
+ }
75
+ function estimatePostRequestBytes(markdown) {
76
+ const escaped = utf8Bytes(JSON.stringify(markdown).slice(1, -1));
77
+ const paragraphs = markdown.split(/\n{2,}/).filter((part) => part.trim().length > 0).length;
78
+ const lines = markdown.length === 0 ? 0 : markdown.split("\n").length;
79
+ return escaped + paragraphs * POST_PARAGRAPH_OVERHEAD_BYTES + lines * POST_LINE_OVERHEAD_BYTES + POST_ENVELOPE_OVERHEAD_BYTES;
80
+ }
81
+ function truncateMarkdown(text, maxBytes = MAX_BODY_BYTES) {
82
+ const bounded = utf16LengthBudget(text, MAX_BODY_UTF16_CHARS);
83
+ const charCapped = bounded !== text;
84
+ if (!charCapped && estimatePostRequestBytes(text) <= maxBytes) return text;
85
+ const suffix = "\n\n\u2026(truncated)";
86
+ const suffixCost = estimatePostRequestBytes(suffix) - POST_ENVELOPE_OVERHEAD_BYTES;
87
+ const budget = charCapped ? Math.min(maxBytes - suffixCost, bounded.length - suffix.length) : maxBytes - suffixCost;
88
+ const snapForward = (candidate) => {
89
+ const newline = candidate.lastIndexOf("\n");
90
+ if (newline < Math.floor(candidate.length * 0.85)) return candidate;
91
+ const snapped = candidate.slice(0, newline);
92
+ return snapped.trim().length > 0 ? snapped : candidate;
93
+ };
94
+ const fit = (limit) => {
95
+ let lo = 0;
96
+ let hi = text.length;
97
+ while (lo < hi) {
98
+ const mid = Math.ceil((lo + hi) / 2);
99
+ if (estimatePostRequestBytes(text.slice(0, mid)) <= limit) lo = mid;
100
+ else hi = mid - 1;
101
+ }
102
+ return text.slice(0, lo);
103
+ };
104
+ let cut = snapForward(stripDanglingHighSurrogate(fit(Math.max(0, budget))));
105
+ if ((cut.match(/```/g)?.length ?? 0) % 2 === 1) {
106
+ const closeCost = estimatePostRequestBytes("\n```") - POST_ENVELOPE_OVERHEAD_BYTES;
107
+ const refit = snapForward(stripDanglingHighSurrogate(fit(Math.max(0, budget - closeCost))));
108
+ cut = (refit.match(/```/g)?.length ?? 0) % 2 === 1 ? refit + "\n```" : refit;
109
+ }
110
+ return cut + suffix;
111
+ }
201
112
  function failureReason(message) {
202
113
  const detail = message.errorMessage?.trim();
203
114
  if (detail) {
@@ -215,110 +126,212 @@ function failureReason(message) {
215
126
  return message.stopReason ? `stop reason: ${message.stopReason}` : "unknown failure";
216
127
  }
217
128
  }
218
- function parseLarkCliEnvelope(stdout) {
219
- let parsed;
129
+
130
+ // builtin/extensions/im-forward/config.ts
131
+ function resolveConfigDir() {
132
+ const value = process.env.PIE_CODING_AGENT_DIR?.trim();
133
+ return value === "~" ? os.homedir() : value?.startsWith("~/") ? os.homedir() + value.slice(1) : value || path.join(os.homedir(), ".pie");
134
+ }
135
+ function configPaths(cwd, dir) {
136
+ return [.../* @__PURE__ */ new Set([path.join(dir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")])];
137
+ }
138
+ var configCache = /* @__PURE__ */ new Map();
139
+ function layerStamp(file) {
220
140
  try {
221
- parsed = JSON.parse(stdout);
141
+ const stat = fs.statSync(file);
142
+ return `${stat.mtimeMs}:${stat.size}`;
222
143
  } catch {
223
- return { errorText: `unparseable lark-cli output: ${stdout.slice(0, 200)}` };
224
- }
225
- if (!isRecord(parsed)) return { errorText: "lark-cli output is not an object" };
226
- if (parsed.ok === true && isRecord(parsed.data)) return { ok: true, data: parsed.data };
227
- if (parsed.code === 0 && isRecord(parsed.data)) return { ok: true, data: parsed.data };
228
- if (parsed.ok === false) {
229
- const error = isRecord(parsed.error) ? JSON.stringify(parsed.error) : "unknown";
230
- return { ok: false, errorText: `lark-cli error: ${error}` };
231
- }
232
- if (typeof parsed.code === "number" && parsed.code !== 0) {
233
- return { ok: false, errorText: `lark-cli error code ${String(parsed.code)}: ${String(parsed.msg ?? "unknown")}` };
144
+ return "-";
234
145
  }
235
- return { errorText: "lark-cli output missing ok field" };
236
146
  }
237
- function extractInboundText(content) {
238
- if (typeof content !== "string") return "";
239
- const trimmed = content.trim();
240
- if (!trimmed) return "";
241
- if (trimmed.startsWith("{")) {
242
- try {
243
- const parsed = JSON.parse(trimmed);
244
- if (isRecord(parsed) && typeof parsed.text === "string" && Object.keys(parsed).length === 1) {
245
- return parsed.text.trim();
246
- }
247
- } catch {
248
- }
249
- }
250
- return trimmed;
147
+ function cachedResolveConfig(cwd, dir) {
148
+ const cacheKey = `${cwd}\0${dir}`;
149
+ const stamp = JSON.stringify(configPaths(cwd, dir).map(layerStamp));
150
+ const cached = configCache.get(cacheKey);
151
+ if (cached && cached.stamp === stamp) return cached.result;
152
+ const result = resolveConfig(cwd, dir);
153
+ configCache.set(cacheKey, { stamp, result });
154
+ return result;
251
155
  }
252
- function hashText(input) {
253
- let hash = 0;
254
- for (let i = 0; i < input.length; i++) {
255
- hash = hash * 31 + input.charCodeAt(i) | 0;
256
- }
257
- return Math.abs(hash).toString(36);
156
+ function invalidateConfigCache() {
157
+ configCache.clear();
258
158
  }
259
- function parseInboundLine(line) {
260
- let parsed;
159
+ function readLayer(file) {
160
+ let text;
161
+ try {
162
+ text = fs.readFileSync(file, "utf8");
163
+ } catch (error) {
164
+ if (error.code === "ENOENT") return {};
165
+ throw new Error(`Cannot read IM configuration: ${file}`);
166
+ }
261
167
  try {
262
- parsed = JSON.parse(line);
168
+ const value = JSON.parse(text);
169
+ if (isRecord(value)) return value;
263
170
  } catch {
264
- return null;
265
171
  }
266
- return isRecord(parsed) ? parsed : null;
172
+ throw new Error(`Invalid IM configuration: ${file}. Repair this file before setup; it will not be overwritten.`);
267
173
  }
268
- function extractImageKeys(content) {
269
- if (typeof content !== "string") return [];
270
- return [...new Set(Array.from(content.matchAll(/\[Image:\s*(img_[A-Za-z0-9_-]+)\]/g), (match) => match[1]))];
174
+ function resolveConfig(cwd, dir) {
175
+ try {
176
+ let present = false;
177
+ let merged = {};
178
+ const providers = {};
179
+ for (const file of configPaths(cwd, dir)) {
180
+ const layer = readLayer(file);
181
+ if (Object.keys(layer).length) present = true;
182
+ const legacy = ["chatId", "receiveId", "openId", "larkCli", "allowUsers"].some((key) => key in layer);
183
+ if (legacy && !layer.provider) {
184
+ const previous = providers.feishu ?? {};
185
+ providers.feishu = { ...previous, ...Object.fromEntries(["chatId", "openId", "larkCli", "allowUsers"].filter((key) => key in layer).map((key) => [key, layer[key]])), ...layer.receiveId && !layer.chatId ? { chatId: layer.receiveId } : {} };
186
+ merged.provider = "feishu";
187
+ }
188
+ if (isRecord(layer.providers)) {
189
+ for (const [id, settings] of Object.entries(layer.providers)) if (isRecord(settings)) providers[id] = { ...providers[id], ...settings };
190
+ }
191
+ merged = { ...merged, ...layer };
192
+ }
193
+ if (!present) return { config: null, problems: [] };
194
+ if ("provider" in merged && (typeof merged.provider !== "string" || !merged.provider.trim())) throw new Error("Invalid IM provider field. Run setup or repair the configuration; no provider fallback was used.");
195
+ const provider = typeof merged.provider === "string" ? merged.provider : "feishu";
196
+ const retry = merged.retryBackoffMs;
197
+ return { config: { enabled: merged.enabled === true, provider, settings: providers[provider] ?? {}, retryBackoffMs: typeof retry === "number" && Number.isFinite(retry) ? Math.min(6e4, Math.max(20, Math.round(retry))) : void 0 }, problems: [] };
198
+ } catch (error) {
199
+ return { config: null, problems: [error.message] };
200
+ }
271
201
  }
272
- function extractImageKey(content) {
273
- return extractImageKeys(content)[0] ?? null;
202
+ function configurationIdentity(config) {
203
+ return JSON.stringify([config.provider, config.settings, config.retryBackoffMs]);
204
+ }
205
+ function configSnapshot(cwd, dir) {
206
+ return JSON.stringify(configPaths(cwd, dir).map((file) => {
207
+ try {
208
+ return fs.readFileSync(file, "utf8");
209
+ } catch (error) {
210
+ if (error.code === "ENOENT") return null;
211
+ throw new Error(`Cannot read IM configuration: ${file}`);
212
+ }
213
+ }));
274
214
  }
275
- function imageExtensionFromKey(imageKey) {
276
- if (imageKey.includes(".gif")) return ".gif";
277
- if (/\.jpe?g/i.test(imageKey)) return ".jpg";
278
- return ".png";
215
+ function saveLocation(cwd, dir) {
216
+ const files = configPaths(cwd, dir);
217
+ return fs.existsSync(files[files.length - 1]) ? files[files.length - 1] : files[0];
279
218
  }
280
- function resolveLarkBinary(explicit) {
281
- if (explicit) {
219
+ function writeConfig(cwd, dir, update, expected) {
220
+ for (const file of configPaths(cwd, dir)) readLayer(file);
221
+ if (expected !== void 0 && configSnapshot(cwd, dir) !== expected) throw new Error("IM configuration changed during setup. Please retry.");
222
+ const target = saveLocation(cwd, dir);
223
+ const value = readLayer(target);
224
+ value.enabled = update.enabled;
225
+ if (update.provider) {
226
+ value.provider = update.provider;
227
+ value.providers = { ...isRecord(value.providers) ? value.providers : {}, [update.provider]: update.settings ?? {} };
228
+ }
229
+ fs.mkdirSync(path.dirname(target), { recursive: true });
230
+ invalidateConfigCache();
231
+ const temporary = path.join(path.dirname(target), `.im-forward-${randomUUID()}.tmp`);
232
+ try {
233
+ const fd = fs.openSync(temporary, "wx", 384);
282
234
  try {
283
- fs.accessSync(explicit, fs.constants.X_OK);
284
- return explicit;
235
+ fs.writeFileSync(fd, JSON.stringify(value, null, " ") + "\n");
236
+ fs.fsyncSync(fd);
237
+ } finally {
238
+ fs.closeSync(fd);
239
+ }
240
+ fs.renameSync(temporary, target);
241
+ } finally {
242
+ try {
243
+ fs.unlinkSync(temporary);
285
244
  } catch {
286
- return null;
287
245
  }
288
246
  }
289
- const suffixes = process.platform === "win32" ? ["lark-cli.exe", "lark-cli.cmd", "lark-cli"] : ["lark-cli"];
290
- for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
291
- if (!dir) continue;
292
- for (const suffix of suffixes) {
293
- const candidate = path.join(dir, suffix);
294
- try {
295
- fs.accessSync(candidate, fs.constants.X_OK);
296
- return candidate;
297
- } catch {
298
- }
247
+ }
248
+ function hasConfigLayers(cwd, dir) {
249
+ return configPaths(cwd, dir).some((file) => fs.existsSync(file));
250
+ }
251
+ async function clearImConfig(cwd, dir, confirm) {
252
+ const files = configPaths(cwd, dir).filter((file) => fs.existsSync(file));
253
+ if (!files.length) return false;
254
+ for (const file of files) readLayer(file);
255
+ const snapshot = configSnapshot(cwd, dir);
256
+ if (!await confirm(`Delete the IM forwarding configuration file(s)?
257
+
258
+ ${files.join("\n")}
259
+
260
+ Forwarding stops immediately and this cannot be undone.`)) return false;
261
+ if (configSnapshot(cwd, dir) !== snapshot) throw new Error("IM configuration changed during clear. Please retry.");
262
+ for (const file of files) {
263
+ try {
264
+ fs.unlinkSync(file);
265
+ } catch (error) {
266
+ if (error.code !== "ENOENT") throw error;
299
267
  }
300
268
  }
301
- return null;
269
+ invalidateConfigCache();
270
+ return true;
302
271
  }
303
- function imForward(ctx) {
304
- if (!ctx.hasUI) return;
305
- const configDir = resolveConfigDir();
306
- const { config, problems } = resolveImForwardConfig(ctx.cwd, configDir);
307
- for (const problem of problems) ctx.log(`im-forward: ${problem}`);
308
- if (!config) return;
309
- const cfg = config;
310
- const resolvedBinary = resolveLarkBinary(cfg.larkCli);
311
- if (!resolvedBinary) {
312
- const message = 'im-forward: lark-cli not found on PATH. Install it (npm i -g @larksuite/cli) or set "larkCli" in ~/.pie/im-forward.json.';
313
- ctx.log(message);
314
- ctx.ui.notify(message, "warning");
315
- return;
272
+ function readLiveEnabled(cwd, dir, _cache) {
273
+ return cachedResolveConfig(cwd, dir).config?.enabled === true;
274
+ }
275
+ function readLiveConfig(cwd, dir) {
276
+ try {
277
+ return cachedResolveConfig(cwd, dir);
278
+ } catch {
279
+ return resolveConfig(cwd, dir);
280
+ }
281
+ }
282
+ function resolveImForwardConfig(cwd, dir) {
283
+ const result = resolveConfig(cwd, dir);
284
+ const cfg = result.config;
285
+ if (!cfg) return { config: null, problems: result.problems };
286
+ if (cfg.provider !== "feishu") return { config: null, problems: [`Unsupported IM provider: ${cfg.provider}`] };
287
+ const raw = cfg.settings;
288
+ const chatId = typeof raw.chatId === "string" ? raw.chatId.trim() : "";
289
+ if (!chatId) return { config: null, problems: cfg.enabled ? ["im-forward: enabled requires chatId"] : [] };
290
+ const allowUsers = Array.isArray(raw.allowUsers) ? raw.allowUsers.filter((id) => typeof id === "string" && !!id.trim()) : void 0;
291
+ return { config: { enabled: cfg.enabled, chatId, allowUsers: allowUsers?.length ? allowUsers : void 0, larkCli: typeof raw.larkCli === "string" && raw.larkCli.trim() ? raw.larkCli.trim() : void 0, retryBackoffMs: cfg.retryBackoffMs }, problems: chatId.startsWith("oc_") ? [] : ["im-forward: chatId does not look like a chat id (oc_...)"] };
292
+ }
293
+
294
+ // builtin/extensions/im-forward/runtime.ts
295
+ var ROUTE_TTL_MS = 24 * 60 * 60 * 1e3;
296
+ var MAX_ROUTES = 500;
297
+ var MAX_SEEN_EVENTS = 1e3;
298
+ var REPLY_CHAIN_MAX_DEPTH = 5;
299
+ var RESTART_BACKOFF_MIN_MS = 5e3;
300
+ var RESTART_BACKOFF_MAX_MS = 6e4;
301
+ var SEND_FAILURE_NOTIFY_THROTTLE_MS = 5 * 60 * 1e3;
302
+ var MAX_INBOUND_RETRIES = 3;
303
+ var DEFAULT_INBOUND_RETRY_BACKOFF_MS = 5e3;
304
+ var MAX_PENDING_IM_INPUTS = 50;
305
+ var PENDING_IM_INPUT_TTL_MS = 60 * 60 * 1e3;
306
+ function summarizeFailureReason(reason) {
307
+ if (!reason) return { summary: "", persistent: false };
308
+ const text = reason.toLowerCase();
309
+ if (/another event bus|already connected/.test(text)) return { summary: "the app's event connection is occupied by another instance (possibly another machine); run /im-forward setup with another app/profile, or stop the other consumer", persistent: true };
310
+ if (/no active profile|not configured/.test(text)) return { summary: "provider not configured", persistent: false };
311
+ if (/auth|unauthorized|token|credential|login/.test(text)) return { summary: "authentication problem", persistent: false };
312
+ if (/permission|forbidden|scope/.test(text)) return { summary: "permission problem", persistent: false };
313
+ if (/network|dns|connect|timeout|unreachable/.test(text)) return { summary: "network problem", persistent: false };
314
+ return { summary: "provider exited", persistent: false };
315
+ }
316
+ var IM_INPUT_FALLBACK_CAPTION = "\u8BF7\u67E5\u770B\u8FD9\u4E9B\u56FE\u7247\u5E76\u5904\u7406\u3002";
317
+ function hashText(input) {
318
+ let hash = 0;
319
+ for (let i = 0; i < input.length; i++) {
320
+ hash = hash * 31 + input.charCodeAt(i) | 0;
316
321
  }
317
- const larkBinary = resolvedBinary;
318
- const enabledCache = {};
322
+ return Math.abs(hash).toString(36);
323
+ }
324
+ function createRuntime(ctx, cfg, connection, liveEnabled, persistEnabled) {
325
+ const IM_INPUT_PREFIX = connection.sourcePrefix;
326
+ const key = (id) => JSON.stringify([cfg.provider, id]);
327
+ const scopes = /* @__PURE__ */ new Set();
319
328
  let forwardingEnabled = cfg.enabled === true;
329
+ let configurationFailed = false;
320
330
  function isForwardingEnabled() {
321
- const live = readLiveEnabled(ctx.cwd, configDir, enabledCache);
331
+ const value = liveEnabled();
332
+ configurationFailed = value === null;
333
+ const live = value === true;
334
+ if (configurationFailed) updateFooterTag();
322
335
  if (live !== forwardingEnabled) {
323
336
  forwardingEnabled = live;
324
337
  updateFooterTag();
@@ -327,39 +340,22 @@ function imForward(ctx) {
327
340
  }
328
341
  const STATUS_KEY = "im-forward";
329
342
  function updateFooterTag() {
330
- ctx.ui.setStatus?.(STATUS_KEY, forwardingEnabled ? "[IM]" : void 0);
343
+ ctx.ui.setStatus?.(STATUS_KEY, configurationFailed ? "[IM!]" : forwardingEnabled ? consumerLastExit ? "[IM!]" : consumerReady ? "[IM]" : "[IM\u2026]" : void 0);
331
344
  }
332
345
  function writeEnabledToConfig(next) {
333
- const userLayer = path.join(configDir, "im-forward.json");
334
- const projectLayer = path.join(ctx.cwd, ".pie", "im-forward.json");
335
- let target = userLayer;
336
- try {
337
- const projectParsed = JSON.parse(fs.readFileSync(projectLayer, "utf8"));
338
- if (isRecord(projectParsed) && "enabled" in projectParsed) target = projectLayer;
339
- } catch {
340
- }
341
- let parsed = {};
342
- try {
343
- parsed = JSON.parse(fs.readFileSync(target, "utf8"));
344
- if (!isRecord(parsed)) parsed = {};
345
- } catch {
346
- }
347
- parsed.enabled = next;
348
- try {
349
- fs.mkdirSync(path.dirname(target), { recursive: true });
350
- fs.writeFileSync(target, `${JSON.stringify(parsed, null, " ")}
351
- `);
352
- } catch (error) {
353
- ctx.log(`im-forward: failed to persist enabled=${next}: ${error instanceof Error ? error.message : String(error)}`);
354
- }
346
+ persistEnabled(next);
355
347
  }
356
348
  let inputEpoch = 0;
357
349
  function toggleForwarding() {
358
350
  inputEpoch++;
359
351
  isForwardingEnabled();
352
+ try {
353
+ writeEnabledToConfig(!forwardingEnabled);
354
+ } catch {
355
+ ctx.ui.notify("IM forwarding: could not save configuration; state unchanged.", "error");
356
+ return;
357
+ }
360
358
  forwardingEnabled = !forwardingEnabled;
361
- writeEnabledToConfig(forwardingEnabled);
362
- enabledCache.enabled = forwardingEnabled;
363
359
  updateFooterTag();
364
360
  if (forwardingEnabled) {
365
361
  ctx.log("im-forward: enabled");
@@ -369,9 +365,8 @@ function imForward(ctx) {
369
365
  ctx.ui.notify("IM forwarding disabled", "info");
370
366
  }
371
367
  }
372
- updateFooterTag();
373
368
  ctx.registerShortcut("ctrl+i", {
374
- description: "Toggle IM forwarding (Feishu)",
369
+ description: "Toggle IM forwarding",
375
370
  handler: toggleForwarding
376
371
  });
377
372
  let currentSessionId = null;
@@ -395,13 +390,19 @@ function imForward(ctx) {
395
390
  ctx.ui.notify(`im-forward: send failed \u2014 ${error}`, "warning");
396
391
  }
397
392
  }
393
+ function removeThreadAliasesOf(route) {
394
+ for (const [threadKey, indexed] of threadRoutes) {
395
+ if (indexed === route) threadRoutes.delete(threadKey);
396
+ }
397
+ }
398
398
  function pruneState(now = Date.now()) {
399
399
  for (const [id, route] of routes) {
400
400
  if (now - route.sentAt > ROUTE_TTL_MS) {
401
401
  routes.delete(id);
402
- if (route.threadId) threadRoutes.delete(route.threadId);
402
+ if (route.threadId) threadRoutes.delete(key(route.threadId));
403
+ removeThreadAliasesOf(route);
403
404
  const anchor = replyAnchor.get(route.sessionId);
404
- if (anchor?.messageId === id) replyAnchor.delete(route.sessionId);
405
+ if (anchor && key(anchor.messageId) === id) replyAnchor.delete(route.sessionId);
405
406
  }
406
407
  }
407
408
  while (routes.size > MAX_ROUTES) {
@@ -410,8 +411,9 @@ function imForward(ctx) {
410
411
  if (!oldest || route.sentAt < oldest.sentAt) oldest = route;
411
412
  }
412
413
  if (!oldest) break;
413
- routes.delete(oldest.larkMessageId);
414
- if (oldest.threadId) threadRoutes.delete(oldest.threadId);
414
+ routes.delete(key(oldest.messageId));
415
+ if (oldest.threadId) threadRoutes.delete(key(oldest.threadId));
416
+ removeThreadAliasesOf(oldest);
415
417
  }
416
418
  const cutoff = now - PENDING_IM_INPUT_TTL_MS;
417
419
  while (pendingInputs.length && pendingInputs[0].at <= cutoff) pendingInputs.shift();
@@ -444,33 +446,30 @@ function imForward(ctx) {
444
446
  for (let j = 0; j < pendingInputs.length; j++) {
445
447
  const pending = pendingInputs[j];
446
448
  if (pending.sessionId !== sessionId) continue;
447
- const expected = (pending.larkMessageId ? `${IM_INPUT_PREFIX}
449
+ const expected = (pending.messageId ? `${IM_INPUT_PREFIX}
448
450
  ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
449
451
  if (lastUserText === expected) {
450
452
  pendingInputs.splice(j, 1);
451
- return { caption: pending.caption || `[Feishu images]`, replyTo: pending.larkMessageId || void 0, threadId: pending.threadId };
453
+ return { caption: pending.caption || `${IM_INPUT_PREFIX} images`, replyTo: pending.messageId || void 0, threadId: pending.threadId };
452
454
  }
453
455
  }
454
456
  return void 0;
455
457
  }
458
+ async function providerCall(operation, action) {
459
+ try {
460
+ return await action();
461
+ } catch {
462
+ ctx.log(`im-forward: ${cfg.provider} ${operation} failed`);
463
+ return null;
464
+ }
465
+ }
456
466
  async function acknowledgeMessage(messageId) {
457
- const { error } = await runLarkCli(larkBinary, [
458
- "im",
459
- "reactions",
460
- "create",
461
- "--as",
462
- "bot",
463
- "--params",
464
- JSON.stringify({ message_id: messageId }),
465
- "--data",
466
- JSON.stringify({ reaction_type: { emoji_type: "THUMBSUP" } })
467
- ]);
468
- if (error) ctx.log(`im-forward: acknowledge react failed: ${error}`);
467
+ await providerCall("acknowledge", () => connection.acknowledge(messageId));
469
468
  }
470
469
  ctx.on("terminal:input", (event) => {
471
470
  const text = event.text?.trim() ?? "";
472
471
  if (text && !text.startsWith(IM_INPUT_PREFIX) && !text.startsWith("/") && !text.startsWith("!!") && !/@\[[^\]]+\]\(file:/.test(text) && currentSessionId) {
473
- pendingInputs.push({ sessionId: currentSessionId, caption: text, larkMessageId: "", threadId: null, at: Date.now() });
472
+ pendingInputs.push({ sessionId: currentSessionId, caption: text, messageId: "", threadId: null, at: Date.now() });
474
473
  pruneState();
475
474
  }
476
475
  return { action: "continue" };
@@ -486,7 +485,7 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
486
485
  if (!sessionId) return;
487
486
  const sequence = ++forwardCounter;
488
487
  const attribution = resolveOutboundAttribution(sessionId);
489
- void forwardToLark(text, sessionId, sequence, attribution?.caption, attribution?.replyTo);
488
+ void forwardMessage(text, sessionId, sequence, attribution?.caption, attribution?.replyTo);
490
489
  });
491
490
  ctx.on("turn:failed", (event) => {
492
491
  if (!isForwardingEnabled()) return;
@@ -495,7 +494,7 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
495
494
  const reason = failureReason(event.message ?? {});
496
495
  const sequence = ++forwardCounter;
497
496
  const attribution = resolveOutboundAttribution(sessionId);
498
- void forwardToLark(`\u26A0\uFE0F \u4EFB\u52A1\u5931\u8D25\uFF1A${reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
497
+ void forwardMessage(`\u26A0\uFE0F \u4EFB\u52A1\u5931\u8D25\uFF1A${reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
499
498
  });
500
499
  ctx.on("turn:cancelled", (event) => {
501
500
  if (!isForwardingEnabled()) return;
@@ -503,82 +502,64 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
503
502
  if (!sessionId) return;
504
503
  const sequence = ++forwardCounter;
505
504
  const attribution = resolveOutboundAttribution(sessionId);
506
- void forwardToLark(`\u26D4\uFE0F \u4EFB\u52A1\u5DF2\u53D6\u6D88\uFF1A${event.reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
505
+ void forwardMessage(`\u26D4\uFE0F \u4EFB\u52A1\u5DF2\u53D6\u6D88\uFF1A${event.reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
507
506
  });
508
- async function forwardToLark(text, sessionId, sequence, userInput, replyTo) {
507
+ async function forwardMessage(text, sessionId, sequence, userInput, replyTo) {
509
508
  pruneState();
510
509
  const sessionName = ctx.getSessionName?.();
511
510
  const composed = composeForwardBody(sessionId, sessionName, userInput, text);
512
- const body = composed.length > MAX_BODY_CHARS ? `${composed.slice(0, MAX_BODY_CHARS)}
513
- \u2026(truncated)` : composed;
514
- const idempotencyKey = `pie-im-forward-${sessionId}-${sequence}-${hashText(composed)}`;
511
+ const body = truncateMarkdown(composed);
512
+ if (terminated || !isForwardingEnabled()) return;
513
+ const idempotencyKey = `pie-im-forward-${cfg.provider}-${sessionId}-${sequence}-${hashText(composed)}`;
515
514
  let anchor = replyTo ?? replyAnchor.get(sessionId)?.messageId;
516
- let args = anchor ? ["im", "+messages-reply", "--as", "bot", "--message-id", anchor, "--text", body, "--idempotency-key", idempotencyKey] : ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
517
- let parsed;
518
- for (let attempt = 0; attempt < 2; attempt++) {
519
- const { stdout, error } = await runLarkCli(larkBinary, args);
520
- if (error) {
521
- if (attempt === 0 && anchor) {
522
- if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
523
- anchorFallbacks++;
524
- anchor = void 0;
525
- args = ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
526
- ctx.log(`im-forward: reply send failed (${error}); falling back to chat root`);
527
- continue;
528
- }
529
- notifySendFailure(`send failed: ${error}`);
530
- return;
531
- }
532
- parsed = parseLarkCliEnvelope(stdout);
533
- if (parsed.ok && parsed.data) break;
534
- if (attempt === 0 && anchor) {
535
- if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
536
- anchor = void 0;
537
- args = ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
538
- ctx.log(`im-forward: reply rejected (${parsed.errorText ?? "no data"}); falling back to chat root`);
539
- continue;
540
- }
541
- notifySendFailure(`send rejected: ${parsed.errorText ?? "no data"}`);
542
- return;
515
+ let sent = await providerCall("send", () => connection.send(cfg.targetId, body, idempotencyKey, anchor));
516
+ if (!sent && anchor && !terminated && isForwardingEnabled()) {
517
+ if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
518
+ anchorFallbacks++;
519
+ sent = await providerCall("send", () => connection.send(cfg.targetId, body, idempotencyKey));
543
520
  }
544
- if (!parsed || !parsed.ok || !parsed.data) return;
545
- const messageId = parsed.data.message_id;
546
- if (typeof messageId === "string" && messageId) {
547
- const threadId = typeof parsed.data.thread_id === "string" ? parsed.data.thread_id : null;
548
- const route = { larkMessageId: messageId, threadId, sessionId, sentAt: Date.now() };
549
- routes.set(messageId, route);
550
- if (threadId) threadRoutes.set(threadId, route);
551
- ctx.log(`im-forward: forwarded session=${sessionId} as ${messageId}${threadId ? ` (thread ${threadId})` : ""}`);
552
- } else {
553
- ctx.log("im-forward: send ok but no message_id in response; reply routing disabled for this message");
521
+ if (!sent) {
522
+ notifySendFailure(`${cfg.provider} send failed`);
523
+ return;
554
524
  }
525
+ const { messageId, threadId } = sent;
526
+ const route = { messageId, threadId, sessionId, sentAt: Date.now() };
527
+ routes.set(key(messageId), route);
528
+ if (threadId) threadRoutes.set(key(threadId), route);
529
+ ctx.log(`im-forward: forwarded session=${sessionId} as ${messageId}`);
530
+ if (terminated || !isForwardingEnabled()) return;
555
531
  }
556
532
  ctx.on("session:changed", (event) => {
557
533
  inputEpoch++;
558
534
  currentSessionId = event.sessionId;
559
- if (event.sessionId && !consumer && !terminated) {
535
+ if (event.sessionId && !consumer && !consumerRecovering && !restartTimer && !terminated && !suspended) {
560
536
  ctx.log("im-forward: session bound, starting event consumer");
561
537
  spawnConsumer();
562
538
  }
563
539
  });
564
540
  async function handleInboundEvent(event, viaRetry = false) {
565
541
  if (!event) return;
566
- if (event.type !== "im.message.receive_v1") return;
567
- if (!event.event_id) return;
568
- if (!viaRetry && inFlightRetries.has(event.event_id)) return;
569
- if (!viaRetry && retryTimers.has(event.event_id)) return;
570
- if (!viaRetry && seenEventIds.has(event.event_id)) return;
571
- seenEventIds.set(event.event_id, Date.now());
542
+ const eventId = event.event_id ? key(event.event_id) : void 0;
543
+ if (!eventId) return;
544
+ if (!viaRetry && inFlightRetries.has(eventId)) return;
545
+ if (!viaRetry && retryTimers.has(eventId)) return;
546
+ if (!viaRetry && seenEventIds.has(eventId)) return;
547
+ if (!isForwardingEnabled()) {
548
+ if (!viaRetry) return;
549
+ }
550
+ if (!retrySessionIds.has(eventId) && currentSessionId) retrySessionIds.set(eventId, currentSessionId);
551
+ seenEventIds.set(eventId, Date.now());
572
552
  pruneState();
573
553
  let scheduledNextRetry = false;
574
554
  const retry = () => {
575
555
  scheduledNextRetry = true;
576
- scheduleInboundRetry(event, retryDepth(event.event_id) + 1);
556
+ scheduleInboundRetry(event, retryDepth(eventId) + 1);
577
557
  };
578
558
  const terminalCleanup = () => {
579
559
  if (!scheduledNextRetry) {
580
- retryAttempts.delete(event.event_id);
581
- inFlightRetries.delete(event.event_id);
560
+ retryAttempts.delete(eventId);
561
+ retrySessionIds.delete(eventId);
562
+ inFlightRetries.delete(eventId);
582
563
  }
583
564
  };
584
565
  if (!isForwardingEnabled()) {
@@ -587,14 +568,18 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
587
568
  }
588
569
  try {
589
570
  await handleInboundEventInner(event, retry);
571
+ } catch {
572
+ ctx.log(`im-forward: ${cfg.provider} inbound handling failed`);
573
+ retry();
590
574
  } finally {
591
575
  terminalCleanup();
592
576
  }
593
577
  }
594
578
  async function handleInboundEventInner(event, retry) {
595
- if (event.chat_id !== cfg.chatId) return;
579
+ const matchesTarget = cfg.targetKind === "user" ? event.sender_id === cfg.targetId && (event.chatType === void 0 || event.chatType === "p2p") : event.chat_id === cfg.targetId;
580
+ if (!matchesTarget) return;
596
581
  if (cfg.allowUsers && (!event.sender_id || !cfg.allowUsers.includes(event.sender_id))) return;
597
- const messageId = event.message_id ?? event.id;
582
+ const messageId = event.message_id;
598
583
  if (!messageId) return;
599
584
  const sessionId = currentSessionId;
600
585
  const epoch = inputEpoch;
@@ -603,6 +588,7 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
603
588
  retry();
604
589
  return;
605
590
  }
591
+ scopes.add(scope);
606
592
  try {
607
593
  const meta = await fetchMessageMeta(messageId);
608
594
  if (!meta) {
@@ -610,17 +596,16 @@ ${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
610
596
  return;
611
597
  }
612
598
  if (!scope.isCurrent()) return;
613
- const route = await resolveRouteForInbound(messageId, meta);
614
- if (!route) {
615
- const resolution = await routeResolutionOutcome(messageId, meta);
616
- if (resolution === "transient") retry();
599
+ const resolution = await resolveRouteForInbound(messageId, meta);
600
+ if (!("found" in resolution)) {
601
+ if (isTransient(resolution)) retry();
617
602
  else ctx.log(`im-forward: no route for ${messageId}; ignoring`);
618
603
  return;
619
604
  }
605
+ const route = resolution.found;
620
606
  if (route.sessionId !== sessionId || !scope.isCurrent()) return;
621
- const text = extractInboundText(event.content);
622
- const imageKeys = extractImageKeys(event.content);
623
- const caption = imageKeys.length ? text.replace(/\[Image:\s*img_[A-Za-z0-9_-]+\]/g, "").trim() : text;
607
+ const imageKeys = event.images;
608
+ const caption = event.text;
624
609
  if (!caption && !imageKeys.length) return;
625
610
  const submitText = `${IM_INPUT_PREFIX}
626
611
  ${caption || IM_INPUT_FALLBACK_CAPTION}`;
@@ -628,47 +613,53 @@ ${caption || IM_INPUT_FALLBACK_CAPTION}`;
628
613
  if (!accepted || !scope.isCurrent()) {
629
614
  if (scope.isCurrent()) {
630
615
  ctx.ui.notify("im-forward: message was not accepted; no input submitted.", "warning");
631
- await forwardToLark(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF0C\u672A\u63D0\u4EA4\u4EFB\u52A1\u3002${imageKeys.length ? "\u8BF7\u68C0\u67E5\u9644\u4EF6\u540E\u91CD\u8BD5\u3002" : "\u8BF7\u91CD\u8BD5\u3002"}`, route.sessionId, ++forwardCounter, caption, messageId);
616
+ await forwardMessage(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF0C\u672A\u63D0\u4EA4\u4EFB\u52A1\u3002${imageKeys.length ? "\u8BF7\u68C0\u67E5\u9644\u4EF6\u540E\u91CD\u8BD5\u3002" : "\u8BF7\u91CD\u8BD5\u3002"}`, route.sessionId, ++forwardCounter, caption, messageId);
632
617
  }
633
618
  return;
634
619
  }
635
620
  replyAnchor.set(route.sessionId, { messageId, at: Date.now() });
636
- pendingInputs.push({ sessionId: route.sessionId, caption, larkMessageId: messageId, threadId: route.threadId, at: Date.now() });
621
+ pendingInputs.push({ sessionId: route.sessionId, caption, messageId, threadId: route.threadId, at: Date.now() });
637
622
  pruneState();
638
623
  ctx.log(`im-forward: accepted ${messageId} into session ${route.sessionId} (${imageKeys.length} images)`);
639
624
  void acknowledgeMessage(messageId);
640
625
  } finally {
626
+ scopes.delete(scope);
641
627
  scope.dispose();
642
628
  }
643
629
  }
644
630
  const retryAttempts = /* @__PURE__ */ new Map();
631
+ const retrySessionIds = /* @__PURE__ */ new Map();
645
632
  function retryDepth(eventId) {
646
633
  return eventId ? retryAttempts.get(eventId) ?? 0 : 0;
647
634
  }
648
635
  const inFlightRetries = /* @__PURE__ */ new Set();
649
636
  function scheduleInboundRetry(event, attempt) {
650
- if (!event.event_id) return;
637
+ const eventId = event.event_id ? key(event.event_id) : void 0;
638
+ if (!eventId) return;
651
639
  if (terminated || attempt > MAX_INBOUND_RETRIES) {
652
640
  if (attempt > MAX_INBOUND_RETRIES) {
653
- ctx.log(`im-forward: giving up on event ${event.event_id} after ${MAX_INBOUND_RETRIES} retries`);
654
- retryAttempts.delete(event.event_id);
655
- void forwardToLark(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF08\u91CD\u8BD5\u7528\u5C3D\uFF09\u3002\u8BF7\u5728 Pie \u7EC8\u7AEF\u67E5\u770B\u65E5\u5FD7\u3002`, currentSessionId ?? "", ++forwardCounter, void 0, event.message_id ?? event.id);
641
+ ctx.log(`im-forward: giving up on event ${eventId} after ${MAX_INBOUND_RETRIES} retries`);
642
+ retryAttempts.delete(eventId);
643
+ const boundSession = retrySessionIds.get(eventId);
644
+ retrySessionIds.delete(eventId);
645
+ void forwardMessage(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF08\u91CD\u8BD5\u7528\u5C3D\uFF09\u3002\u8BF7\u5728 Pie \u7EC8\u7AEF\u67E5\u770B\u65E5\u5FD7\u3002`, boundSession ?? currentSessionId ?? "", ++forwardCounter, void 0, event.message_id);
656
646
  }
657
647
  return;
658
648
  }
659
649
  const backoff = (cfg.retryBackoffMs ?? DEFAULT_INBOUND_RETRY_BACKOFF_MS) * 2 ** (attempt - 1);
660
- if (retryTimers.has(event.event_id)) return;
661
- retryAttempts.set(event.event_id, attempt);
650
+ if (retryTimers.has(eventId)) return;
651
+ retryAttempts.set(eventId, attempt);
652
+ if (!retrySessionIds.has(eventId) && currentSessionId) retrySessionIds.set(eventId, currentSessionId);
662
653
  const timer = setTimeout(() => {
663
- retryTimers.delete(event.event_id);
664
- inFlightRetries.add(event.event_id);
654
+ retryTimers.delete(eventId);
655
+ inFlightRetries.add(eventId);
665
656
  void handleInboundEvent(event, true).finally(() => {
666
- inFlightRetries.delete(event.event_id);
657
+ inFlightRetries.delete(eventId);
667
658
  });
668
659
  }, backoff);
669
660
  timer.unref?.();
670
- retryTimers.set(event.event_id, { attempt, timer, event });
671
- ctx.log(`im-forward: transient inbound failure; retrying ${event.event_id} in ${backoff}ms (attempt ${attempt}/${MAX_INBOUND_RETRIES})`);
661
+ retryTimers.set(eventId, { attempt, timer, event });
662
+ ctx.log(`im-forward: transient inbound failure; retrying ${eventId} in ${backoff}ms (attempt ${attempt}/${MAX_INBOUND_RETRIES})`);
672
663
  }
673
664
  ctx.__forceInboundRetriesForTest = () => {
674
665
  for (const [eventId, entry] of retryTimers) {
@@ -677,275 +668,243 @@ ${caption || IM_INPUT_FALLBACK_CAPTION}`;
677
668
  void handleInboundEvent({ ...entry.event ?? {} }, true);
678
669
  }
679
670
  };
680
- async function routeResolutionOutcome(messageId, meta) {
681
- if (!meta.threadId) {
682
- if (!meta.replyTo) return "not-ours";
683
- let current = meta.replyTo;
684
- const visited = /* @__PURE__ */ new Set();
685
- for (let depth = 0; depth < REPLY_CHAIN_MAX_DEPTH && current && !visited.has(current); depth++) {
686
- visited.add(current);
687
- const { stdout: stdout2, error: error2 } = await runLarkCli(larkBinary, ["im", "+messages-mget", "--as", "bot", "--message-ids", current]);
688
- if (error2) return "transient";
689
- const parsed = parseLarkCliEnvelope(stdout2);
690
- if (!parsed.ok || !parsed.data) return "transient";
691
- const messages = parsed.data.messages;
692
- if (!Array.isArray(messages) || !isRecord(messages[0])) return "transient";
693
- const next = messages[0].reply_to;
694
- if (typeof next !== "string" || !next) return "not-ours";
695
- current = next;
696
- }
697
- return "not-ours";
698
- }
699
- const { stdout, error } = await runLarkCli(larkBinary, [
700
- "im",
701
- "+threads-messages-list",
702
- "--as",
703
- "bot",
704
- "--thread",
705
- meta.threadId
706
- ]);
707
- if (error) return "transient";
708
- return parseLarkCliEnvelope(stdout).ok ? "not-ours" : "transient";
709
- }
671
+ const isTransient = (resolution) => "transient" in resolution && resolution.transient === true;
710
672
  async function injectImageMessage(messageId, imageKeys, submitText, scope) {
711
673
  if (imageKeys.length > 10) {
712
674
  ctx.log("im-forward: refusing more than 10 images in one message");
713
675
  return false;
714
676
  }
715
- let tempDir;
677
+ const downloads = [];
716
678
  try {
717
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pie-im-forward-"));
718
- const files = [];
719
- for (const [index, imageKey] of imageKeys.entries()) {
679
+ for (const [index, image] of imageKeys.entries()) {
720
680
  if (!scope.isCurrent()) return false;
721
- const ext = imageExtensionFromKey(imageKey);
722
- const fileName = `image-${index + 1}${ext}`;
723
- const { stdout, error } = await runLarkCli(larkBinary, [
724
- "im",
725
- "+messages-resources-download",
726
- "--as",
727
- "bot",
728
- "--message-id",
729
- messageId,
730
- "--file-key",
731
- imageKey,
732
- "--type",
733
- "image",
734
- "--output",
735
- fileName
736
- ], CLI_CALL_TIMEOUT_MS, tempDir);
737
- if (error || !parseLarkCliEnvelope(stdout).ok) throw new Error(error || "image download rejected");
738
- if (!scope.isCurrent()) return false;
739
- let target = path.join(tempDir, fileName);
740
- if (fs.statSync(target).size > 10 * 1024 * 1024) throw new Error("image exceeds 10 MB inline limit");
741
- const detected = readImageFormat(target);
742
- if (detected.ext === ".bin") throw new Error("download is not a supported image");
743
- if (detected.ext !== ext) {
744
- const corrected = path.join(tempDir, `image-${index + 1}${detected.ext}`);
745
- fs.renameSync(target, corrected);
746
- target = corrected;
747
- }
748
- files.push({ path: target, displayName: `feishu-image-${index + 1}${detected.ext}` });
681
+ downloads.push(await connection.downloadImage(messageId, image, index));
749
682
  }
750
- return await scope.submit(submitText, files);
751
- } catch (error) {
752
- ctx.log(`im-forward: image input failed: ${error instanceof Error ? error.message : String(error)}`);
683
+ if (!scope.isCurrent()) return false;
684
+ return await scope.submit(submitText, downloads.map((item) => item.file));
685
+ } catch {
686
+ ctx.log("im-forward: image preparation failed");
753
687
  return false;
754
688
  } finally {
755
- if (tempDir) {
689
+ for (const item of downloads) {
756
690
  try {
757
- fs.rmSync(tempDir, { recursive: true, force: true });
758
- } catch (error) {
759
- ctx.log(`im-forward: temporary image cleanup failed: ${String(error)}`);
691
+ item.cleanup();
692
+ } catch {
693
+ ctx.log("im-forward: temporary image cleanup failed");
760
694
  }
761
695
  }
762
696
  }
763
697
  }
764
698
  async function fetchMessageMeta(messageId) {
765
- const { stdout, error } = await runLarkCli(larkBinary, [
766
- "im",
767
- "+messages-mget",
768
- "--as",
769
- "bot",
770
- "--message-ids",
771
- messageId
772
- ]);
773
- if (error) {
774
- ctx.log(`im-forward: mget failed: ${error}`);
775
- return null;
776
- }
777
- const parsed = parseLarkCliEnvelope(stdout);
778
- if (!parsed.ok || !parsed.data) return null;
779
- const messages = parsed.data.messages;
780
- if (!Array.isArray(messages) || !isRecord(messages[0])) return null;
781
- const message = messages[0];
782
- const threadId = typeof message.thread_id === "string" && message.thread_id ? message.thread_id : null;
783
- const replyTo = typeof message.reply_to === "string" && message.reply_to ? message.reply_to : null;
784
- return { threadId, replyTo };
699
+ return providerCall("metadata", () => connection.metadata(messageId));
700
+ }
701
+ async function fetchThreadMessages(threadId) {
702
+ return providerCall("thread lookup", () => connection.threadMessages(threadId));
785
703
  }
786
704
  async function resolveRouteForInbound(messageId, meta) {
787
705
  if (meta.threadId) {
788
- const byThread = threadRoutes.get(meta.threadId);
789
- if (byThread) return byThread;
706
+ const byThread = threadRoutes.get(key(meta.threadId));
707
+ if (byThread) return { found: byThread };
790
708
  }
791
- const direct = routes.get(messageId);
792
- if (direct) return direct;
709
+ const direct = routes.get(key(messageId));
710
+ if (direct) return { found: direct };
711
+ let chain = { notOurs: true };
793
712
  if (meta.replyTo) {
794
- const viaChain = await resolveRouteForReply(meta.replyTo);
795
- if (viaChain) return viaChain;
713
+ chain = await resolveRouteForReply(meta.replyTo);
714
+ if ("found" in chain) return chain;
796
715
  }
797
- if (meta.threadId) return await resolveRouteByThreadRoot(meta.threadId);
798
- return null;
716
+ if (!meta.threadId) return chain;
717
+ const byRoot = await resolveRouteByThreadRoot(meta.threadId);
718
+ if ("found" in byRoot) return byRoot;
719
+ return isTransient(chain) || isTransient(byRoot) ? { transient: true } : { notOurs: true };
799
720
  }
800
721
  async function resolveRouteByThreadRoot(threadId) {
801
- const { stdout, error } = await runLarkCli(larkBinary, [
802
- "im",
803
- "+threads-messages-list",
804
- "--as",
805
- "bot",
806
- "--thread",
807
- threadId
808
- ]);
809
- if (error) {
810
- ctx.log(`im-forward: thread list failed: ${error}`);
811
- return null;
812
- }
813
- const parsed = parseLarkCliEnvelope(stdout);
814
- if (!parsed.ok || !parsed.data) return null;
815
- const messages = parsed.data.messages;
816
- if (!Array.isArray(messages)) return null;
817
- for (const message of messages) {
818
- if (!isRecord(message)) continue;
819
- const id = message.message_id;
820
- if (typeof id !== "string") continue;
821
- const route = routes.get(id);
722
+ const messages = await fetchThreadMessages(threadId);
723
+ if (!messages) return { transient: true };
724
+ for (const id of messages) {
725
+ const route = routes.get(key(id));
822
726
  if (route) {
823
- threadRoutes.set(threadId, route);
824
- return route;
727
+ threadRoutes.set(key(threadId), route);
728
+ return { found: route };
825
729
  }
826
730
  }
827
- return null;
731
+ return { notOurs: true };
828
732
  }
829
733
  async function resolveRouteForReply(startId) {
830
734
  let current = startId;
831
735
  const visited = /* @__PURE__ */ new Set();
832
736
  for (let depth = 0; depth < REPLY_CHAIN_MAX_DEPTH; depth++) {
833
- const direct = routes.get(current);
834
- if (direct) return direct;
835
- if (visited.has(current)) return null;
737
+ const direct = routes.get(key(current));
738
+ if (direct) return { found: direct };
739
+ if (visited.has(current)) return { notOurs: true };
836
740
  visited.add(current);
837
- const parent = await fetchReplyTo(current);
838
- if (!parent) return null;
839
- current = parent;
741
+ const parent = await fetchMessageMeta(current);
742
+ if (!parent) return { transient: true };
743
+ if (!parent.replyTo) return { notOurs: true };
744
+ current = parent.replyTo;
840
745
  }
841
- return null;
842
- }
843
- async function fetchReplyTo(messageId) {
844
- const { stdout, error } = await runLarkCli(larkBinary, [
845
- "im",
846
- "+messages-mget",
847
- "--as",
848
- "bot",
849
- "--message-ids",
850
- messageId
851
- ]);
852
- if (error) {
853
- ctx.log(`im-forward: mget failed: ${error}`);
854
- return null;
855
- }
856
- const parsed = parseLarkCliEnvelope(stdout);
857
- if (!parsed.ok || !parsed.data) return null;
858
- const messages = parsed.data.messages;
859
- if (!Array.isArray(messages) || !isRecord(messages[0])) return null;
860
- const replyTo = messages[0].reply_to;
861
- return typeof replyTo === "string" && replyTo ? replyTo : null;
746
+ return { notOurs: true };
862
747
  }
863
748
  let consumer = null;
864
749
  let consumerReady = false;
750
+ let consumerRecovering = false;
865
751
  let consumerRestarts = 0;
866
752
  let consumerLastExit;
753
+ let notifiedPersistentFailure = false;
867
754
  let terminated = false;
868
755
  let restartTimer;
869
- let notifiedUnreadyExit = false;
870
- function spawnConsumer() {
871
- if (terminated) return;
872
- const child = spawn2(larkBinary, ["event", "consume", "im.message.receive_v1", "--as", "bot"], {
873
- stdio: ["pipe", "pipe", "pipe"]
756
+ let generation;
757
+ function stopGeneration(gen) {
758
+ if (gen.startup) clearTimeout(gen.startup);
759
+ return gen.stopping ??= Promise.resolve().then(() => gen.handle?.stop()).catch(() => {
760
+ ctx.log(`im-forward: ${cfg.provider} consumer stop failed`);
761
+ }).then(() => {
762
+ gen.retired = true;
874
763
  });
875
- const state = { child, stopped: false };
876
- consumer = state;
764
+ }
765
+ function spawnConsumer() {
766
+ restartTimer = void 0;
767
+ restartDueAt = void 0;
768
+ if (terminated || suspended || consumer || consumerRecovering) return;
769
+ const gen = { handle: null, installed: false, failed: false, retired: false };
770
+ generation = gen;
877
771
  consumerReady = false;
878
- let buffer = "";
879
- child.stdin?.on("error", () => void 0);
880
- child.stdout?.on("data", (chunk) => {
881
- buffer += chunk.toString("utf8");
882
- let newline = buffer.indexOf("\n");
883
- while (newline >= 0) {
884
- const line = buffer.slice(0, newline).trim();
885
- buffer = buffer.slice(newline + 1);
886
- if (line) void handleInboundEvent(parseInboundLine(line)).catch((error) => {
887
- ctx.log(`im-forward: inbound handling failed: ${error instanceof Error ? error.message : String(error)}`);
888
- });
889
- newline = buffer.indexOf("\n");
772
+ updateFooterTag();
773
+ const recover = () => {
774
+ if (!gen.installed) return;
775
+ void stopGeneration(gen).then(() => {
776
+ if (generation !== gen) return;
777
+ consumer = null;
778
+ consumerRecovering = false;
779
+ if (terminated || suspended) return;
780
+ const backoff = Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
781
+ restartTimer = setTimeout(spawnConsumer, backoff);
782
+ restartTimer.unref?.();
783
+ restartDueAt = Date.now() + backoff;
784
+ });
785
+ };
786
+ const fail = (reason) => {
787
+ if (gen.failed || gen.retired || terminated || generation !== gen) return;
788
+ gen.drain = gen.installed;
789
+ gen.failed = true;
790
+ if (gen.startup) clearTimeout(gen.startup);
791
+ consumerRecovering = true;
792
+ consumerReady = false;
793
+ const { summary: safe, persistent } = summarizeFailureReason(reason);
794
+ consumerLastExit = safe ? `${cfg.provider} connection failed (${safe})` : `${cfg.provider} connection failed`;
795
+ consumerRestarts++;
796
+ updateFooterTag();
797
+ ctx.log(`im-forward: consumer exited (${consumerLastExit})`);
798
+ if (!persistent || !notifiedPersistentFailure) {
799
+ if (persistent) notifiedPersistentFailure = true;
800
+ ctx.ui.notify(`IM forwarding: ${cfg.provider} connection failed${safe ? `: ${safe}` : "; check provider configuration/authentication."}${persistent ? " Retrying in the background (see /im-forward status)." : " Retrying."}`, persistent ? "error" : "warning");
890
801
  }
891
- });
892
- const stderrTail = [];
893
- child.stderr?.on("data", (chunk) => {
894
- for (const raw of chunk.toString("utf8").split("\n")) {
895
- const line = raw.trim();
896
- if (!line) continue;
897
- stderrTail.push(line);
898
- if (stderrTail.length > 20) stderrTail.shift();
899
- if (line.startsWith("[event] ready")) {
802
+ recover();
803
+ };
804
+ gen.startup = setTimeout(fail, 3e4);
805
+ gen.startup.unref?.();
806
+ try {
807
+ gen.handle = connection.consume({
808
+ // Preserve buffered EXIT tail-drain until stop/close settles.
809
+ // A synchronous startup failure has no live stream to drain.
810
+ event: (event) => {
811
+ if (!terminated && !gen.retired && generation === gen && (!gen.failed || gen.drain))
812
+ void handleInboundEvent(event);
813
+ },
814
+ ready: () => {
815
+ if (terminated || gen.failed || gen.retired || generation !== gen) return;
816
+ clearTimeout(gen.startup);
900
817
  consumerReady = true;
901
- consumerRestarts = 0;
818
+ notifiedPersistentFailure = false;
902
819
  ctx.log("im-forward: event consumer ready");
903
- }
904
- }
905
- });
906
- child.on("exit", (code, signal) => {
907
- if (state.stopped || terminated) return;
908
- consumerLastExit = `code=${code} signal=${signal}`;
909
- consumerRestarts += 1;
910
- ctx.log(`im-forward: consumer exited (${consumerLastExit})`);
911
- if (!consumerReady && !notifiedUnreadyExit) {
912
- notifiedUnreadyExit = true;
913
- ctx.ui.notify(
914
- `im-forward: lark-cli event consumer exited before becoming ready (${consumerLastExit}). Check lark-cli config/auth; retries continue in the background. ${stderrTail.slice(-3).join(" | ")}`,
915
- "warning"
916
- );
917
- }
918
- const backoff = Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
919
- restartTimer = setTimeout(spawnConsumer, backoff);
920
- restartTimer.unref?.();
921
- });
820
+ consumerRestarts = 0;
821
+ consumerLastExit = void 0;
822
+ updateFooterTag();
823
+ },
824
+ failure: fail
825
+ });
826
+ consumer = gen.handle;
827
+ } catch {
828
+ fail();
829
+ }
830
+ gen.installed = true;
831
+ if (gen.failed) recover();
922
832
  }
923
- ctx.registerCleanup?.(() => {
833
+ let suspended = false;
834
+ let suspendedRestartInMs;
835
+ let restartDueAt;
836
+ const scheduleRestart = (delayMs) => {
837
+ if (restartTimer) clearTimeout(restartTimer);
838
+ restartDueAt = Date.now() + delayMs;
839
+ restartTimer = setTimeout(spawnConsumer, delayMs);
840
+ restartTimer.unref?.();
841
+ };
842
+ ctx.suspendEventConsumer = async () => {
843
+ if (terminated || suspended) return () => {
844
+ };
845
+ inputEpoch++;
846
+ suspended = true;
847
+ const gen = generation;
848
+ if (restartTimer) {
849
+ suspendedRestartInMs = restartDueAt !== void 0 ? Math.max(0, restartDueAt - Date.now()) : 0;
850
+ clearTimeout(restartTimer);
851
+ restartTimer = void 0;
852
+ restartDueAt = void 0;
853
+ }
854
+ consumer = null;
855
+ consumerRecovering = false;
856
+ consumerReady = false;
857
+ if (gen && !gen.retired) await stopGeneration(gen);
858
+ if (terminated) return () => {
859
+ };
860
+ let resumed = false;
861
+ return () => {
862
+ if (resumed || terminated) return;
863
+ resumed = true;
864
+ if (!suspended) return;
865
+ suspended = false;
866
+ if (consumer || consumerRecovering || restartTimer) return;
867
+ const delay = suspendedRestartInMs !== void 0 ? suspendedRestartInMs : Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
868
+ suspendedRestartInMs = void 0;
869
+ scheduleRestart(delay);
870
+ };
871
+ };
872
+ updateFooterTag();
873
+ ctx.registerCleanup?.(async () => {
924
874
  inputEpoch++;
925
875
  terminated = true;
876
+ const resources = [...scopes];
877
+ scopes.clear();
926
878
  for (const { timer } of retryTimers.values()) clearTimeout(timer);
927
879
  retryTimers.clear();
928
880
  retryAttempts.clear();
881
+ retrySessionIds.clear();
929
882
  inFlightRetries.clear();
930
883
  if (restartTimer) clearTimeout(restartTimer);
931
- if (consumer && !consumer.child.killed) {
932
- consumer.stopped = true;
933
- consumer.child.kill("SIGTERM");
934
- }
884
+ if (generation) generation.retired = true;
885
+ const results = await Promise.allSettled([
886
+ ...resources.map((scope) => Promise.resolve().then(() => scope.dispose())),
887
+ Promise.resolve().then(() => connection.dispose?.()),
888
+ generation ? stopGeneration(generation) : Promise.resolve()
889
+ ]);
890
+ consumer = null;
891
+ if (results.some((result) => result.status === "rejected")) ctx.log(`im-forward: ${cfg.provider} resource cleanup failed`);
935
892
  });
936
893
  ctx.registerCommand({
937
894
  name: "im-forward",
938
- description: "IM forwarding status (Feishu via lark-cli)",
895
+ description: "IM forwarding status",
939
896
  handler: (commandCtx) => {
897
+ isForwardingEnabled();
940
898
  const lines = [
941
- "IM forwarding (Feishu via lark-cli)",
942
- `- chat: ${cfg.chatId}`,
943
- `- allowUsers: ${cfg.allowUsers?.join(", ") ?? "(anyone in chat)"}`,
944
- `- binary: ${larkBinary}`,
899
+ `IM forwarding (${cfg.label ?? cfg.provider})`,
900
+ `- target: ${cfg.targetId}`,
901
+ ...cfg.appId ? [`- app: ${cfg.appId}`] : [],
902
+ `- allowed senders: ${cfg.allowUsers?.join(", ") ?? "(anyone in target)"}`,
945
903
  `- consumer: ${terminated ? "stopped" : consumer ? consumerReady ? "ready" : "starting" : "idle (waits for first session)"}${consumerLastExit ? ` (last exit: ${consumerLastExit})` : ""}, restarts=${consumerRestarts}`,
946
904
  `- routes: ${routes.size} forwarded message(s) routable (${threadRoutes.size} thread(s), ${replyAnchor.size} anchor(s))`,
947
905
  `- current session: ${currentSessionId ?? "(none)"}`
948
906
  ];
907
+ if (!routes.size && !terminated && consumerReady) lines.push("- note: no forwarded message yet \u2014 IM replies are only accepted after the first answer is forwarded. Run any local turn to open the reply entry.");
949
908
  if (lastSendError) lines.push(`- last send error: ${lastSendError} (at ${new Date(lastSendErrorAt).toISOString()})`);
950
909
  if (anchorFallbacks) lines.push(`- anchor fallbacks to chat root: ${anchorFallbacks}`);
951
910
  if (pendingInputs.length) lines.push(`- pending turn attributions: ${pendingInputs.length}`);
@@ -953,28 +912,1367 @@ ${caption || IM_INPUT_FALLBACK_CAPTION}`;
953
912
  commandCtx.ui.notify(lines.join("\n"), "info");
954
913
  }
955
914
  });
956
- function resolveConfigDir() {
957
- const envDir = process.env.PIE_CODING_AGENT_DIR?.trim();
958
- if (envDir) {
959
- if (envDir === "~") return os.homedir();
960
- if (envDir.startsWith("~/")) return os.homedir() + envDir.slice(1);
961
- return envDir;
915
+ }
916
+
917
+ // builtin/extensions/im-forward/onboarding.ts
918
+ async function runSetup(ctx, dir, providers, signal, isCurrent = () => !signal.aborted, intent = "first-run", suspendEventConsumer) {
919
+ const initial = resolveConfig(ctx.cwd, dir);
920
+ if (initial.problems.length) {
921
+ ctx.ui.notify(initial.problems.join("\n"), "error");
922
+ return;
923
+ }
924
+ const snapshot = configSnapshot(ctx.cwd, dir);
925
+ const options = { signal, timeout: 12e4 };
926
+ if (!providers.length || !isCurrent()) return;
927
+ let selected;
928
+ if (intent === "switch-app" && initial.config) {
929
+ const bound = providers.find((item) => item.id === initial.config?.provider);
930
+ if (providers.length === 1 && bound) selected = bound;
931
+ }
932
+ if (!selected) {
933
+ const choice = await ctx.ui.select("IM forwarding setup \u2014 platform", [...providers.map((item) => item.label), "Cancel"], options);
934
+ if (!isCurrent() || !choice || choice === "Cancel") return;
935
+ selected = providers.find((item) => item.label === choice);
936
+ }
937
+ if (!selected || !isCurrent()) return;
938
+ ctx.ui.notify(`IM forwarding setup \u2014 ${selected.label}`, "info");
939
+ const settings = await selected.setup({ ui: ctx.ui, cwd: ctx.cwd, signal, suspendEventConsumer }, initial.config?.provider === selected.id ? initial.config.settings : void 0, void 0, intent);
940
+ if (!isCurrent() || !settings) return;
941
+ const resolved = selected.resolve(settings);
942
+ if (!resolved) {
943
+ ctx.ui.notify("IM forwarding setup: invalid provider settings; nothing was saved.", "error");
944
+ return;
945
+ }
946
+ const profile = typeof settings.larkCliProfile === "string" ? settings.larkCliProfile : void 0;
947
+ const profileLine = profile ? `
948
+ lark-cli profile: ${String(profile)}` : "";
949
+ const appLine = resolved.appId ? `
950
+ App: ${resolved.appId}` : "";
951
+ const confirmed = await ctx.ui.confirm("IM forwarding setup", `Platform: ${selected.label}
952
+ Target: ${resolved.targetId}${appLine}${profileLine}
953
+ Allowed senders: ${resolved.allowUsers?.join(", ") || "anyone in target"}
954
+ Save location: ${saveLocation(ctx.cwd, dir)}
955
+ Enable forwarding and start the connection? No test message will be sent. Saving does not prove connection readiness.`, options);
956
+ if (!isCurrent() || !confirmed) return;
957
+ writeConfig(ctx.cwd, dir, { enabled: true, provider: selected.id, settings }, snapshot);
958
+ return resolveConfig(ctx.cwd, dir).config ?? void 0;
959
+ }
960
+
961
+ // builtin/extensions/im-forward/providers/feishu/index.ts
962
+ import { spawn as spawn4 } from "node:child_process";
963
+ import * as fs4 from "node:fs";
964
+ import * as os2 from "node:os";
965
+ import * as path6 from "node:path";
966
+
967
+ // builtin/extensions/im-forward/lark-cli.ts
968
+ import { spawn as spawn2 } from "node:child_process";
969
+ import { StringDecoder as StringDecoder2 } from "node:string_decoder";
970
+
971
+ // builtin/extensions/im-forward/launch.ts
972
+ import { spawn } from "node:child_process";
973
+ import * as fs2 from "node:fs";
974
+ import * as path2 from "node:path";
975
+ import { StringDecoder } from "node:string_decoder";
976
+ function resolveLaunchSpec(explicit, cwd = process.cwd(), platform = process.platform, env = process.env) {
977
+ const windows = platform === "win32";
978
+ const candidates = explicit ? [path2.resolve(cwd, explicit)] : (env.PATH ?? "").split(windows ? ";" : ":").filter(Boolean).flatMap((dir) => (windows ? ["lark-cli.exe", "lark-cli.cmd"] : ["lark-cli"]).map((name) => path2.resolve(cwd, dir, name)));
979
+ for (const candidate of candidates) {
980
+ try {
981
+ if (!fs2.statSync(candidate).isFile()) continue;
982
+ const ext = path2.extname(candidate).toLowerCase();
983
+ if (ext === ".js" || ext === ".mjs") return { command: process.execPath, prefix: [candidate] };
984
+ if (windows && ext === ".cmd") {
985
+ const dir = path2.dirname(candidate);
986
+ for (const root of [path2.join(dir, "node_modules", "@larksuite", "cli"), path2.resolve(dir, "..", "@larksuite", "cli")]) {
987
+ try {
988
+ const pkg = JSON.parse(fs2.readFileSync(path2.join(root, "package.json"), "utf8"));
989
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["lark-cli"];
990
+ if (pkg.name !== "@larksuite/cli" || typeof bin !== "string" || bin.replace(/^\.\//, "") !== "scripts/run.js") continue;
991
+ const entry = path2.join(root, "scripts", "run.js");
992
+ if (fs2.statSync(entry).isFile()) return { command: process.execPath, prefix: [entry] };
993
+ } catch {
994
+ }
995
+ }
996
+ continue;
997
+ }
998
+ if (windows && ext !== ".exe") continue;
999
+ fs2.accessSync(candidate, fs2.constants.X_OK);
1000
+ return { command: candidate, prefix: [] };
1001
+ } catch {
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+ var treeStops = /* @__PURE__ */ new WeakMap();
1007
+ var hasExited = (child) => child.exitCode != null || child.signalCode != null;
1008
+ function stopOwnedTree(child, signal) {
1009
+ const existing = treeStops.get(child);
1010
+ if (existing) return existing;
1011
+ if (process.platform === "win32" && hasExited(child)) return Promise.resolve(false);
1012
+ const pending = stopOwnedTreeInner(child, signal);
1013
+ treeStops.set(child, pending);
1014
+ void pending.finally(() => treeStops.delete(child));
1015
+ return pending;
1016
+ }
1017
+ function stopOwnedTreeInner(child, signal) {
1018
+ if (process.platform !== "win32" || !child.pid) {
1019
+ try {
1020
+ if (child.pid) process.kill(-child.pid, signal);
1021
+ else child.kill(signal);
1022
+ return Promise.resolve(true);
1023
+ } catch {
1024
+ try {
1025
+ return Promise.resolve(child.kill(signal));
1026
+ } catch {
1027
+ return Promise.resolve(false);
1028
+ }
1029
+ }
1030
+ }
1031
+ return new Promise((resolve3) => {
1032
+ let done = false;
1033
+ let killer;
1034
+ const finish = (ok) => {
1035
+ if (done) return;
1036
+ done = true;
1037
+ clearTimeout(timer);
1038
+ if (!ok && !hasExited(child)) {
1039
+ try {
1040
+ child.kill(signal);
1041
+ } catch {
1042
+ }
1043
+ }
1044
+ resolve3(ok);
1045
+ };
1046
+ const timer = setTimeout(() => {
1047
+ try {
1048
+ killer?.kill();
1049
+ } catch {
1050
+ }
1051
+ finish(false);
1052
+ }, 1500);
1053
+ try {
1054
+ const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
1055
+ killer = spawn(path2.join(root, "System32", "taskkill.exe"), ["/PID", String(child.pid), "/T", "/F"], { shell: false, windowsHide: true, stdio: "ignore" });
1056
+ killer.on("error", () => finish(false));
1057
+ killer.on("exit", (code) => finish(code === 0));
1058
+ } catch {
1059
+ finish(false);
1060
+ }
1061
+ });
1062
+ }
1063
+ function lineReader(onLine, max = 1024 * 1024) {
1064
+ const decoder = new StringDecoder("utf8");
1065
+ let pending = "";
1066
+ let dropping = false;
1067
+ const accept = (text) => {
1068
+ for (const [i, part] of text.split("\n").entries()) {
1069
+ if (i) {
1070
+ if (!dropping) onLine(pending);
1071
+ pending = "";
1072
+ dropping = false;
1073
+ }
1074
+ if (!dropping) {
1075
+ pending += part;
1076
+ if (pending.length > max) {
1077
+ pending = "";
1078
+ dropping = true;
1079
+ }
1080
+ }
1081
+ }
1082
+ };
1083
+ return { write: (chunk) => accept(decoder.write(chunk)), end() {
1084
+ accept(decoder.end());
1085
+ if (pending && !dropping) onLine(pending);
1086
+ pending = "";
1087
+ } };
1088
+ }
1089
+ var shutdowns = /* @__PURE__ */ new WeakMap();
1090
+ function shutdownConsumer(child) {
1091
+ const existing = shutdowns.get(child);
1092
+ if (existing) return existing;
1093
+ const pending = shutdownConsumerInner(child);
1094
+ shutdowns.set(child, pending);
1095
+ return pending;
1096
+ }
1097
+ async function shutdownConsumerInner(child) {
1098
+ let closed = false;
1099
+ const close = new Promise((resolve3) => child.once("close", () => {
1100
+ closed = true;
1101
+ resolve3();
1102
+ }));
1103
+ const wait = async (ms) => {
1104
+ let timer;
1105
+ await Promise.race([close, new Promise((resolve3) => {
1106
+ timer = setTimeout(resolve3, ms);
1107
+ })]);
1108
+ clearTimeout(timer);
1109
+ };
1110
+ try {
1111
+ child.stdin?.end();
1112
+ } catch {
1113
+ }
1114
+ await wait(500);
1115
+ if (!closed) {
1116
+ await stopOwnedTree(child, "SIGTERM");
1117
+ await wait(500);
1118
+ }
1119
+ if (!closed) {
1120
+ await stopOwnedTree(child, "SIGKILL");
1121
+ await wait(500);
1122
+ }
1123
+ child.stdin?.destroy();
1124
+ child.stdout?.destroy();
1125
+ child.stderr?.destroy();
1126
+ }
1127
+
1128
+ // builtin/extensions/im-forward/lark-cli.ts
1129
+ async function runLarkCli(binary, args, timeoutMs = 3e4, cwd, options = {}) {
1130
+ return new Promise((resolve3) => {
1131
+ if (options.signal?.aborted) {
1132
+ resolve3({ stdout: "", error: "cancelled" });
1133
+ return;
1134
+ }
1135
+ let child;
1136
+ try {
1137
+ const spec = typeof binary === "string" ? resolveLaunchSpec(binary) ?? (options.spawn ? { command: binary, prefix: [] } : null) : binary;
1138
+ if (!spec) throw new Error("unsupported executable");
1139
+ child = (options.spawn ?? spawn2)(spec.command, [...spec.prefix, ...args], {
1140
+ shell: false,
1141
+ windowsHide: true,
1142
+ stdio: [options.stdin ?? "ignore", "pipe", "pipe"],
1143
+ detached: process.platform !== "win32",
1144
+ ...cwd ? { cwd } : {}
1145
+ });
1146
+ } catch {
1147
+ resolve3({ stdout: "", error: "spawn failed" });
1148
+ return;
1149
+ }
1150
+ let stdout = "";
1151
+ let stderr = "";
1152
+ let settled = false;
1153
+ let closed = false;
1154
+ let termination;
1155
+ let treeStopped = false;
1156
+ let killTimer;
1157
+ let closeTimer;
1158
+ const finish = (result) => {
1159
+ if (settled) return;
1160
+ settled = true;
1161
+ clearTimeout(timer);
1162
+ clearTimeout(killTimer);
1163
+ clearTimeout(closeTimer);
1164
+ options.signal?.removeEventListener("abort", abort);
1165
+ resolve3(result);
1166
+ };
1167
+ const terminatedResult = () => ({ stdout: termination?.startsWith("timeout") ? stdout : "", error: termination });
1168
+ const stop = (signal) => {
1169
+ try {
1170
+ return stopOwnedTree(child, signal);
1171
+ } catch {
1172
+ return Promise.resolve();
1173
+ }
1174
+ };
1175
+ const kill = () => {
1176
+ const stopped = () => {
1177
+ treeStopped = true;
1178
+ if (settled) return;
1179
+ if (closed) {
1180
+ finish(terminatedResult());
1181
+ return;
1182
+ }
1183
+ closeTimer = setTimeout(() => {
1184
+ child.stdout?.destroy();
1185
+ child.stderr?.destroy();
1186
+ finish(terminatedResult());
1187
+ }, options.closeGraceMs ?? 1e3);
1188
+ };
1189
+ void stop("SIGKILL").then(stopped, stopped);
1190
+ };
1191
+ const terminate = (reason, graceful = false) => {
1192
+ if (settled || termination) return;
1193
+ termination = reason;
1194
+ clearTimeout(timer);
1195
+ if (graceful) {
1196
+ const stopped = () => {
1197
+ if (closed) {
1198
+ treeStopped = true;
1199
+ finish(terminatedResult());
1200
+ }
1201
+ };
1202
+ void stop("SIGTERM").then(stopped, stopped);
1203
+ if (!settled) killTimer = setTimeout(kill, options.killGraceMs ?? 1e3);
1204
+ } else kill();
1205
+ };
1206
+ const timer = setTimeout(() => terminate(`timeout after ${timeoutMs}ms`, true), timeoutMs);
1207
+ const abort = () => terminate("cancelled");
1208
+ options.signal?.addEventListener("abort", abort, { once: true });
1209
+ if (options.signal?.aborted) abort();
1210
+ const deliverLine = (line) => {
1211
+ if (settled || termination) return;
1212
+ try {
1213
+ options.onLine?.(line);
1214
+ } catch {
1215
+ terminate("output callback failed");
1216
+ }
1217
+ };
1218
+ const lines = options.onLine ? lineReader(deliverLine, 16384) : void 0;
1219
+ const errorLines = options.onLine ? lineReader(deliverLine, 16384) : void 0;
1220
+ const outDecoder = new StringDecoder2("utf8");
1221
+ const errDecoder = new StringDecoder2("utf8");
1222
+ child.stdout?.on("data", (chunk) => {
1223
+ if (settled || termination) return;
1224
+ lines?.write(chunk);
1225
+ if (termination) return;
1226
+ stdout += outDecoder.write(chunk);
1227
+ if (stdout.length > 8 * 1024 * 1024) {
1228
+ stdout = "";
1229
+ terminate("stdout exceeds 8 MiB limit");
1230
+ }
1231
+ });
1232
+ child.stderr?.on("data", (chunk) => {
1233
+ if (settled || termination) return;
1234
+ errorLines?.write(chunk);
1235
+ stderr = (stderr + errDecoder.write(chunk)).slice(-4096);
1236
+ });
1237
+ child.on("error", () => terminate("spawn error"));
1238
+ child.on("close", (code) => {
1239
+ closed = true;
1240
+ if (settled) return;
1241
+ if (termination) {
1242
+ if (treeStopped) finish(terminatedResult());
1243
+ return;
1244
+ }
1245
+ stdout += outDecoder.end();
1246
+ stderr += errDecoder.end();
1247
+ lines?.end();
1248
+ errorLines?.end();
1249
+ if (termination) return;
1250
+ if (code === 0 && (stdout.trim() || options.allowEmpty)) finish({ stdout });
1251
+ else finish({ stdout, error: `exit ${code}${stderr ? `: ${stderr.slice(-300).trim()}` : ""}` });
1252
+ });
1253
+ });
1254
+ }
1255
+
1256
+ // builtin/extensions/im-forward/providers/feishu/helpers.ts
1257
+ function parseLarkCliEnvelope(stdout) {
1258
+ let parsed;
1259
+ try {
1260
+ parsed = JSON.parse(stdout);
1261
+ } catch {
1262
+ return { errorText: `unparseable lark-cli output: ${stdout.slice(0, 200)}` };
1263
+ }
1264
+ if (!isRecord(parsed)) return { errorText: "lark-cli output is not an object" };
1265
+ if (parsed.ok === true && isRecord(parsed.data)) return { ok: true, data: parsed.data };
1266
+ if (parsed.code === 0 && isRecord(parsed.data)) return { ok: true, data: parsed.data };
1267
+ if (parsed.ok === false) {
1268
+ const error = isRecord(parsed.error) ? JSON.stringify(parsed.error) : "unknown";
1269
+ return { ok: false, errorText: `lark-cli error: ${error}` };
1270
+ }
1271
+ if (typeof parsed.code === "number" && parsed.code !== 0) {
1272
+ return { ok: false, errorText: `lark-cli error code ${String(parsed.code)}: ${String(parsed.msg ?? "unknown")}` };
1273
+ }
1274
+ return { errorText: "lark-cli output missing ok field" };
1275
+ }
1276
+ function extractInboundText(content) {
1277
+ if (typeof content !== "string") return "";
1278
+ const trimmed = content.trim();
1279
+ if (!trimmed) return "";
1280
+ if (trimmed.startsWith("{")) {
1281
+ try {
1282
+ const parsed = JSON.parse(trimmed);
1283
+ if (isRecord(parsed) && typeof parsed.text === "string" && Object.keys(parsed).length === 1) {
1284
+ return parsed.text.trim();
1285
+ }
1286
+ } catch {
962
1287
  }
963
- return path.join(os.homedir(), ".pie");
964
1288
  }
1289
+ return trimmed;
1290
+ }
1291
+ function extractImageKeys(content) {
1292
+ if (typeof content !== "string") return [];
1293
+ return [...new Set(Array.from(content.matchAll(/\[Image:\s*(img_[A-Za-z0-9_-]+)\]/g), (match) => match[1]))];
1294
+ }
1295
+ function extractImageKey(content) {
1296
+ return extractImageKeys(content)[0] ?? null;
1297
+ }
1298
+
1299
+ // builtin/extensions/im-forward/providers/feishu/setup.ts
1300
+ import * as fs3 from "node:fs";
1301
+ import * as path5 from "node:path";
1302
+
1303
+ // builtin/extensions/im-forward/browser.ts
1304
+ import { spawn as spawn3 } from "node:child_process";
1305
+ import path3 from "node:path";
1306
+ var browserScript = "$ErrorActionPreference = 'Stop'; try { $reader = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8); $url = $reader.ReadToEnd(); Start-Process -FilePath $url; exit 0 } catch { exit 1 }";
1307
+ function browserLaunch(platform = process.platform, root = process.env.SystemRoot || "C:\\Windows") {
1308
+ if (platform === "win32") {
1309
+ if (!/^[A-Za-z]:\\/.test(root) || /[\x00-\x1f]/.test(root)) return;
1310
+ return { command: path3.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(browserScript, "utf16le").toString("base64")], stdin: true };
1311
+ }
1312
+ if (platform === "darwin") return { command: "/usr/bin/open", args: ["--"], stdin: false };
1313
+ if (platform === "linux") return { command: "/usr/bin/xdg-open", args: [], stdin: false };
1314
+ }
1315
+ async function openBrowser(url, signal, seam = {}) {
1316
+ if (signal.aborted || !/^https:\/\/[^\s\x00-\x1f\x7f]+$/.test(url)) return "failed";
1317
+ const spec = browserLaunch(seam.platform);
1318
+ if (!spec) return "failed";
1319
+ return new Promise((resolve3) => {
1320
+ let child;
1321
+ let settled = false;
1322
+ let stopping = false;
1323
+ let closed = false;
1324
+ let closeTimer;
1325
+ const finish = (result) => {
1326
+ if (settled) return;
1327
+ settled = true;
1328
+ clearTimeout(timer);
1329
+ clearTimeout(closeTimer);
1330
+ signal.removeEventListener("abort", abort);
1331
+ child?.stdin?.destroy();
1332
+ child?.stdout?.destroy();
1333
+ child?.stderr?.destroy();
1334
+ resolve3(result);
1335
+ };
1336
+ const stop = () => {
1337
+ if (settled || stopping) return;
1338
+ stopping = true;
1339
+ const stopped = () => {
1340
+ if (closed) finish("failed");
1341
+ else closeTimer = setTimeout(() => finish("failed"), 1e3);
1342
+ };
1343
+ try {
1344
+ void (seam.stop ?? stopOwnedTree)(child, "SIGKILL").then(stopped, stopped);
1345
+ } catch {
1346
+ stopped();
1347
+ }
1348
+ };
1349
+ const abort = () => stop();
1350
+ const timer = setTimeout(stop, seam.timeoutMs ?? 1e4);
1351
+ try {
1352
+ child = (seam.spawn ?? spawn3)(spec.command, spec.stdin ? spec.args : [...spec.args, url], { shell: false, windowsHide: true, detached: process.platform !== "win32", stdio: [spec.stdin ? "pipe" : "ignore", "ignore", "ignore"] });
1353
+ child.on("error", stop);
1354
+ child.on("close", (code) => {
1355
+ closed = true;
1356
+ if (!stopping) finish(code === 0 && !signal.aborted ? "launched" : "failed");
1357
+ });
1358
+ child.stdin?.on("error", stop);
1359
+ signal.addEventListener("abort", abort, { once: true });
1360
+ if (signal.aborted) stop();
1361
+ else if (spec.stdin) child.stdin?.end(Buffer.from(url, "utf8"));
1362
+ } catch {
1363
+ finish("failed");
1364
+ }
1365
+ });
1366
+ }
1367
+
1368
+ // builtin/extensions/im-forward/existing-app-terminal.ts
1369
+ import path4 from "node:path";
1370
+ var existingAppInnerScript = "$ErrorActionPreference = 'Stop'; try { $payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) | ConvertFrom-Json; Set-Location -LiteralPath $payload.cwd; & $payload.command @($payload.args); $code = $LASTEXITCODE; if ($null -eq $code) { exit 1 }; exit $code } catch { Write-Host 'Official setup failed. Close this window and retry from Pie.'; exit 1 }";
1371
+ var existingAppOuterTemplate = "$ErrorActionPreference = 'Stop'; try { $inner = '" + existingAppInnerScript.replaceAll("'", "''") + "'; $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)); $child = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -WindowStyle Normal -Wait -PassThru -ArgumentList @('-NoLogo', '-NoProfile', '-EncodedCommand', $encoded); exit $child.ExitCode } catch { exit 1 }";
1372
+ function existingAppTerminal(binary, cwd, platform = process.platform, root = process.env.SystemRoot || "C:\\Windows") {
1373
+ if (platform !== "win32" || !/^[A-Za-z]:\\/.test(root) || /[\x00-\x1f]/.test(root)) return null;
1374
+ const payload = Buffer.from(JSON.stringify({ command: binary.command, args: [...binary.prefix, "config", "init", "--brand", "feishu"], cwd }), "utf8").toString("base64");
1375
+ const script = existingAppOuterTemplate.replace("__PAYLOAD__", payload);
1376
+ return { command: path4.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), prefix: ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")] };
1377
+ }
1378
+
1379
+ // builtin/extensions/im-forward/providers/feishu/setup.ts
1380
+ function findNpmCli() {
1381
+ const dirs = [path5.dirname(process.execPath), ...(process.env.PATH ?? "").split(path5.delimiter)];
1382
+ for (const dir of dirs) for (const root of [path5.join(dir, "node_modules", "npm"), path5.resolve(dir, "..", "lib", "node_modules", "npm")]) {
1383
+ try {
1384
+ const pkg = JSON.parse(fs3.readFileSync(path5.join(root, "package.json"), "utf8"));
1385
+ const entry = path5.join(root, "bin", "npm-cli.js");
1386
+ if (pkg.name === "npm" && pkg.bin?.npm === "bin/npm-cli.js" && fs3.statSync(entry).isFile()) return { command: process.execPath, prefix: [entry] };
1387
+ } catch {
1388
+ }
1389
+ }
1390
+ return null;
1391
+ }
1392
+ function isOfficialAuthUrl(raw) {
1393
+ try {
1394
+ if (!/^https:\/\/[^\s\x00-\x1f\x7f]+$/.test(raw) || /%0[0-9a-f]|%1[0-9a-f]|%7f/i.test(raw)) return false;
1395
+ const url = new URL(raw);
1396
+ return url.protocol === "https:" && !url.username && !url.password && !raw.includes("#") && (!url.port || url.port === "443") && ["feishu.cn", "larksuite.com"].some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`)) && url.pathname === "/page/cli" && !!url.searchParams.get("user_code") && ![...url.searchParams.keys()].some((key) => /secret|token/i.test(key));
1397
+ } catch {
1398
+ return false;
1399
+ }
1400
+ }
1401
+ function authUrls(line, newAppFlow = false) {
1402
+ const urls = [];
1403
+ const visit = (value) => {
1404
+ if (!isRecord(value)) return;
1405
+ for (const [key, child] of Object.entries(value)) {
1406
+ if (["verification_url", "verification_uri_complete", "console_url"].includes(key) && typeof child === "string" && /^https?:\/\/[^\s\x00-\x1f\x7f]+$/.test(child)) urls.push(child);
1407
+ else if (isRecord(child)) visit(child);
1408
+ }
1409
+ };
1410
+ try {
1411
+ visit(JSON.parse(line));
1412
+ } catch {
1413
+ const match = line.trim().match(/^"(verification_url|verification_uri_complete|console_url)"\s*:\s*("(?:[^"\\]|\\.)*")\s*,?$/);
1414
+ if (match) {
1415
+ try {
1416
+ visit({ [match[1]]: JSON.parse(match[2]) });
1417
+ } catch {
1418
+ }
1419
+ }
1420
+ const field = line.trim().match(/^(verification_url|verification_uri_complete|console_url)\s*[:=]\s*(https?:\/\/\S+)$/);
1421
+ if (field) visit({ [field[1]]: field[2] });
1422
+ }
1423
+ if (newAppFlow) {
1424
+ const raw = line.trim();
1425
+ try {
1426
+ if (isOfficialAuthUrl(raw)) urls.push(raw);
1427
+ } catch {
1428
+ }
1429
+ }
1430
+ return [...new Set(urls)];
1431
+ }
1432
+ function parseDirectMessage(line) {
1433
+ let raw;
1434
+ try {
1435
+ raw = JSON.parse(line);
1436
+ } catch {
1437
+ return null;
1438
+ }
1439
+ if (!isRecord(raw) || raw.type !== "im.message.receive_v1") return null;
1440
+ if ("chat_type" in raw && raw.chat_type !== "p2p") return null;
1441
+ if (typeof raw.chat_id !== "string" || !/^oc_[A-Za-z0-9_-]+$/.test(raw.chat_id)) return null;
1442
+ if (typeof raw.sender_id !== "string" || !/^ou_[A-Za-z0-9_-]+$/.test(raw.sender_id)) return null;
1443
+ return { chatId: raw.chat_id, senderId: raw.sender_id };
1444
+ }
1445
+ function parseChats(stdout) {
1446
+ const parsed = parseLarkCliEnvelope(stdout);
1447
+ if (!parsed.ok || !parsed.data) return null;
1448
+ const list = parsed.data.chats ?? parsed.data.items ?? parsed.data.messages;
1449
+ if (!Array.isArray(list)) return parsed.data.has_more === false ? [] : null;
1450
+ return list.flatMap((item) => isRecord(item) && typeof item.chat_id === "string" && /^oc_[A-Za-z0-9_-]+$/.test(item.chat_id) ? [{ id: item.chat_id, name: typeof item.name === "string" ? item.name.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) : item.chat_id }] : []);
1451
+ }
1452
+ var failureActions = {
1453
+ config: "Official CLI configuration is missing or invalid. Create a new app, or configure an existing app in a trusted terminal.",
1454
+ auth: "Authorization is missing or expired. Configure the intended app in a trusted terminal, then recheck.",
1455
+ permission: "The bot lacks access or permissions. Check app scopes and chat membership, then recheck.",
1456
+ network: "The service could not be reached. Check connectivity and retry the connection check.",
1457
+ unsupported: "This CLI command or version is unsupported. Check the official CLI version and update it in a trusted terminal.",
1458
+ timeout: "The operation timed out. Check connectivity and retry.",
1459
+ cancel: "The operation was cancelled. Retry when ready; nothing was saved.",
1460
+ unknown: "The CLI returned an unrecognized failure or invalid result. Check the official CLI in a trusted terminal, then retry."
1461
+ };
1462
+ function setupFailure(result) {
1463
+ let type = "";
1464
+ let subtype = "";
1465
+ try {
1466
+ const body = JSON.parse(result.stdout);
1467
+ if (isRecord(body) && isRecord(body.error)) {
1468
+ type = typeof body.error.type === "string" ? body.error.type.toLowerCase() : "";
1469
+ subtype = typeof body.error.subtype === "string" ? body.error.subtype.toLowerCase() : "";
1470
+ }
1471
+ } catch {
1472
+ }
1473
+ const tags = `${type} ${subtype}`;
1474
+ let reason = "unknown";
1475
+ if (/^timeout/.test(result.error ?? "")) reason = "timeout";
1476
+ else if (result.error === "cancelled") reason = "cancel";
1477
+ else if (/config|not_configured/.test(tags)) reason = "config";
1478
+ else if (/permission|forbidden|scope|access_denied/.test(tags)) reason = "permission";
1479
+ else if (/auth|token|credential|unauthorized/.test(tags)) reason = "auth";
1480
+ else if (/network|connection|dns|transport/.test(tags)) reason = "network";
1481
+ else if (/unsupported|unknown_command|invalid_command|version/.test(tags)) reason = "unsupported";
1482
+ return failureActions[reason];
1483
+ }
1484
+ function parseProfiles(stdout) {
1485
+ let parsed;
1486
+ try {
1487
+ parsed = JSON.parse(stdout);
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ if (!Array.isArray(parsed)) return null;
1492
+ const profiles = parsed.flatMap((item) => {
1493
+ if (!isRecord(item) || typeof item.name !== "string" || !item.name.trim() || typeof item.appId !== "string") return [];
1494
+ return [{ name: item.name.trim(), appId: item.appId, active: item.active === true, user: typeof item.user === "string" ? item.user : void 0 }];
1495
+ });
1496
+ return profiles.length ? profiles : null;
1497
+ }
1498
+ var dependencies = { resolve: resolveLaunchSpec, run: runLarkCli, npm: findNpmCli, openBrowser, terminal: existingAppTerminal };
1499
+ async function setupFeishu(io, previous, deps = dependencies, intent = "first-run") {
1500
+ const { ui, cwd, signal } = io;
1501
+ const options = { signal, timeout: 12e4 };
1502
+ const current = () => !signal.aborted;
1503
+ const run = async (binary2, args, timeout, workdir, callOptions = {}, authorize = false, progressLabel = "", stream) => {
1504
+ const operation = new AbortController();
1505
+ let dialog = new AbortController();
1506
+ let dialogGeneration = 0;
1507
+ let dialogStarted = false;
1508
+ const startedAt = Date.now();
1509
+ const abort = () => operation.abort();
1510
+ signal.addEventListener("abort", abort, { once: true });
1511
+ if (signal.aborted) operation.abort();
1512
+ let expired = false;
1513
+ const deadline = setTimeout(() => {
1514
+ expired = true;
1515
+ operation.abort();
1516
+ }, timeout);
1517
+ let done = false;
1518
+ let streamStopped = false;
1519
+ const openings = [];
1520
+ const seen = /* @__PURE__ */ new Set();
1521
+ let received = false;
1522
+ const announceUrl = (url) => {
1523
+ if (!isOfficialAuthUrl(url)) {
1524
+ ui.notify("An authorization link arrived but could not be verified as an official Feishu page; it was NOT displayed or opened. If authorization fails, retry setup in a trusted terminal.", "warning");
1525
+ widget("Unverified link withheld \u2014 not opened automatically", "");
1526
+ return;
1527
+ }
1528
+ ui.notify(url, "info");
1529
+ widget("Opening default browser", url);
1530
+ const opening = Promise.resolve().then(() => active() ? deps.openBrowser?.(url, operation.signal) : "failed").then((result) => {
1531
+ widget(result === "launched" ? "Waiting for browser authorization (launcher completed; authorization not yet confirmed)" : "Browser could not open; manually open link", url);
1532
+ }, () => {
1533
+ widget("Browser could not open; manually open link", url);
1534
+ }).catch(() => {
1535
+ });
1536
+ openings.push(opening);
1537
+ };
1538
+ const active = () => !done && current() && !operation.signal.aborted;
1539
+ let phase = "Requesting official authorization link";
1540
+ let manualUrl = "";
1541
+ const showProgress = () => {
1542
+ if (!active()) return;
1543
+ const previous2 = dialog;
1544
+ dialog = new AbortController();
1545
+ const generation = ++dialogGeneration;
1546
+ previous2.abort();
1547
+ const title = "IM forwarding setup \u2014 working; cancel to stop" + (authorize ? `
1548
+ Browser authorization: ${phase}
1549
+ ${manualUrl || "No authorization URL yet. Do not enter secrets in Pie."}` : progressLabel ? `
1550
+ ${progressLabel}` : "");
1551
+ const cancel = () => {
1552
+ if (generation === dialogGeneration && active()) operation.abort();
1553
+ };
1554
+ try {
1555
+ void ui.select(title, ["Cancel"], { signal: dialog.signal, timeout: Math.max(1, timeout - (Date.now() - startedAt)) }).then(cancel, cancel);
1556
+ } catch {
1557
+ cancel();
1558
+ }
1559
+ };
1560
+ const widget = (nextPhase, url = "") => {
1561
+ if (!active()) return;
1562
+ phase = nextPhase;
1563
+ manualUrl = url;
1564
+ try {
1565
+ ui.setWidget?.("im-forward-setup", ["IM forwarding \u2014 browser authorization", phase, manualUrl || "No authorization URL yet. Do not enter secrets in Pie."]);
1566
+ } catch {
1567
+ }
1568
+ if (dialogStarted) showProgress();
1569
+ };
1570
+ if (authorize) widget("Requesting official authorization link");
1571
+ const advisory = authorize ? setTimeout(() => {
1572
+ if (!received) widget("Still waiting for the official authorization link; cancel or wait (two-minute deadline).");
1573
+ }, 15e3) : void 0;
1574
+ try {
1575
+ const pending = deps.run(binary2, args, timeout, workdir, { ...callOptions, signal: operation.signal, ...authorize ? { onLine: (line) => {
1576
+ if (!active()) return;
1577
+ for (const url of authUrls(line, true)) {
1578
+ if (seen.has(url)) continue;
1579
+ seen.add(url);
1580
+ received = true;
1581
+ announceUrl(url);
1582
+ }
1583
+ } } : stream ? { onLine: (line) => {
1584
+ if (!active()) return;
1585
+ if (stream(line)) {
1586
+ streamStopped = true;
1587
+ operation.abort();
1588
+ }
1589
+ } } : {} }).then(async (result2) => {
1590
+ if (openings.length) await Promise.all(openings);
1591
+ done = true;
1592
+ return result2;
1593
+ });
1594
+ dialogStarted = true;
1595
+ showProgress();
1596
+ const result = await pending;
1597
+ if (operation.signal.aborted && !expired && !streamStopped) throw new Error("Operation cancelled; nothing was saved.");
1598
+ return expired ? { stdout: "", error: `timeout after ${timeout}ms` } : result;
1599
+ } finally {
1600
+ done = true;
1601
+ ++dialogGeneration;
1602
+ operation.abort();
1603
+ try {
1604
+ await Promise.all(openings);
1605
+ } finally {
1606
+ clearTimeout(advisory);
1607
+ clearTimeout(deadline);
1608
+ dialog.abort();
1609
+ signal.removeEventListener("abort", abort);
1610
+ if (authorize) {
1611
+ try {
1612
+ ui.setWidget?.("im-forward-setup", void 0);
1613
+ } catch {
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+ };
1619
+ let executable = typeof previous?.larkCli === "string" ? previous.larkCli : void 0;
1620
+ let binary = deps.resolve(executable, cwd);
1621
+ while (!binary && current()) {
1622
+ const choice = await ui.select("IM forwarding setup \u2014 Feishu CLI required", ["Provide existing executable path", "Install official @larksuite/cli", "Cancel"], options);
1623
+ if (!current() || !choice || choice === "Cancel") return;
1624
+ if (choice === "Install official @larksuite/cli") {
1625
+ if (!await ui.confirm("Install Feishu CLI?", "This runs npm install --global @larksuite/cli: network access, global filesystem changes and package postinstall scripts. No messages will be sent. Continue?", options) || !current()) return;
1626
+ const npm = deps.npm();
1627
+ if (!npm) {
1628
+ ui.notify("A reliable npm-cli.js was not found. Install @larksuite/cli yourself using your trusted npm, then provide its executable path. No installer was run.", "warning");
1629
+ continue;
1630
+ }
1631
+ const installed = await run(npm, ["install", "--global", "@larksuite/cli"], 12e4, cwd, { signal, allowEmpty: true });
1632
+ if (!current()) return;
1633
+ if (installed.error) {
1634
+ ui.notify("Feishu CLI installation failed or timed out. Configuration was not saved. Install with your trusted npm or provide an existing path.", "error");
1635
+ return;
1636
+ }
1637
+ executable = void 0;
1638
+ binary = deps.resolve(void 0, cwd);
1639
+ if (!binary) ui.notify("Installer completed, but lark-cli is not discoverable. Provide its executable path.", "warning");
1640
+ } else {
1641
+ const input = await ui.input("IM forwarding setup \u2014 existing Feishu CLI executable", "Absolute executable or Node .js/.mjs entry (not a shell command)", options);
1642
+ if (!current() || !input?.trim()) return;
1643
+ executable = input.trim();
1644
+ binary = deps.resolve(executable, cwd);
1645
+ if (!binary) ui.notify("That executable is missing or unsupported. Windows npm cmd shims must belong to the official CLI; arbitrary bat/cmd wrappers are not supported.", "warning");
1646
+ }
1647
+ }
1648
+ if (!binary || !current()) return;
1649
+ let profileName = typeof previous?.larkCliProfile === "string" && previous.larkCliProfile.trim() ? previous.larkCliProfile.trim() : void 0;
1650
+ let profiles = null;
1651
+ {
1652
+ const listed = await run(binary, ["profile", "list"], 15e3, cwd, { signal, allowEmpty: true });
1653
+ if (!current()) return;
1654
+ profiles = listed.error ? null : parseProfiles(listed.stdout);
1655
+ const describe = (item) => {
1656
+ const parts = [item.appId];
1657
+ if (item.user) parts.push(item.user);
1658
+ if (item.active) parts.push("active");
1659
+ return `${item.name} (${parts.join(" \xB7 ")})`;
1660
+ };
1661
+ const connectDifferent = "Connect a different app\u2026";
1662
+ const suggestProfileName = () => {
1663
+ const taken = new Set((profiles ?? []).map((item) => item.name));
1664
+ if (!taken.has("pie")) return "pie";
1665
+ for (let n = 2; n < 100; n++) if (!taken.has(`pie-${n}`)) return `pie-${n}`;
1666
+ return `pie-${Date.now().toString(36)}`;
1667
+ };
1668
+ const bindNewApp = async () => {
1669
+ if (!await ui.confirm("Connect a different Feishu app?", "The default browser will open for authorization. This creates an external Feishu application as a new lark-cli profile, not just a sign-in. The official CLI will write its own configuration; other profiles stay unchanged. Pie does not collect app secrets. Continue?", options) || !current()) return false;
1670
+ let suggested = suggestProfileName();
1671
+ const entered = await ui.input("IM forwarding setup \u2014 profile name for the new app", `Suggested: ${suggested} (stored in lark-cli, local to this machine)`, options);
1672
+ if (!current()) return false;
1673
+ const raw = (entered ?? "").trim();
1674
+ if (raw) {
1675
+ if (!/^[-\w]{1,64}$/.test(raw)) {
1676
+ ui.notify("Invalid profile name: use 1-64 letters, digits, - or _. Nothing was saved.", "error");
1677
+ return false;
1678
+ }
1679
+ suggested = raw;
1680
+ }
1681
+ ui.notify("Authorize only in the official browser flow. Do not enter secrets in Pie. Open the exact URL displayed below; setup is bounded to two minutes.", "info");
1682
+ const result = await run(binary, ["config", "init", "--new", "--brand", "feishu", "--name", suggested], 12e4, cwd, { signal, allowEmpty: true }, true);
1683
+ if (!current()) return false;
1684
+ if (result.error) {
1685
+ ui.notify(`New-app browser authorization: ${setupFailure(result)} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
1686
+ return false;
1687
+ }
1688
+ profileName = suggested;
1689
+ const relisted = await run(binary, ["profile", "list"], 15e3, cwd, { signal, allowEmpty: true });
1690
+ if (!current()) return false;
1691
+ if (!relisted.error) {
1692
+ const fresh = parseProfiles(relisted.stdout);
1693
+ if (fresh) profiles = fresh;
1694
+ }
1695
+ ui.notify(`IM forwarding will use lark-cli profile "${suggested}" on this machine.`, "info");
1696
+ return true;
1697
+ };
1698
+ const singleProfile = profiles?.length === 1 ? profiles[0] : void 0;
1699
+ if (singleProfile && intent === "switch-app") {
1700
+ const choice = await ui.select("IM forwarding setup \u2014 Feishu app (per machine)", [`Keep current app \u2014 ${describe(singleProfile)}`, connectDifferent, "Cancel"], options);
1701
+ if (!current() || !choice || choice === "Cancel") return;
1702
+ if (choice === connectDifferent) {
1703
+ if (!await bindNewApp()) return;
1704
+ } else profileName = singleProfile.name;
1705
+ } else if (profiles && profiles.length > 1) {
1706
+ const previousProfile = profiles.find((item) => item.name === profileName);
1707
+ if (!previousProfile) profileName = void 0;
1708
+ const items = ["Use the CLI's active app (no per-machine profile)", ...profiles.map(describe), connectDifferent, "Cancel"];
1709
+ const choice = await ui.select("IM forwarding setup \u2014 Feishu app (per machine)", items, options);
1710
+ if (!current() || !choice || choice === "Cancel") return;
1711
+ if (choice === connectDifferent) {
1712
+ if (!await bindNewApp()) return;
1713
+ } else {
1714
+ const index = items.indexOf(choice) - 1;
1715
+ profileName = choice === items[0] ? void 0 : profiles[index]?.name;
1716
+ if (!profileName && choice !== items[0]) return;
1717
+ if (profileName) ui.notify(`IM forwarding will use lark-cli profile "${profileName}" on this machine.`, "info");
1718
+ }
1719
+ } else if (profiles) {
1720
+ profileName = void 0;
1721
+ } else if (intent === "switch-app") {
1722
+ ui.notify("This lark-cli build does not support multiple app profiles; update lark-cli to switch apps.", "warning");
1723
+ }
1724
+ }
1725
+ const withProfile = (args) => profileName ? [...args, "--profile", profileName] : args;
1726
+ let diagnostic = "";
1727
+ const chatListCommands = [
1728
+ ["im", "+chat-list", "--as", "bot", "--format", "json", "--page-size", "100"],
1729
+ ["im", "chats", "list", "--as", "bot", "--format", "json", "--params", '{"page_size":100}']
1730
+ ].map(withProfile);
1731
+ let chatListForm;
1732
+ const discover = async () => {
1733
+ let firstDiagnostic = "";
1734
+ for (const args of chatListForm ? [chatListForm] : chatListCommands) {
1735
+ const result = await run(binary, args, 3e4, cwd, { signal });
1736
+ if (!firstDiagnostic) firstDiagnostic = setupFailure(result);
1737
+ diagnostic = setupFailure(result);
1738
+ if (!result.error) {
1739
+ const chats2 = parseChats(result.stdout);
1740
+ if (chats2) {
1741
+ chatListForm = args;
1742
+ return chats2;
1743
+ }
1744
+ }
1745
+ }
1746
+ if (firstDiagnostic && firstDiagnostic !== failureActions.unknown) diagnostic = firstDiagnostic;
1747
+ return null;
1748
+ };
1749
+ let chats = await discover();
1750
+ if (!current()) return;
1751
+ let stage = "Connection check before authorization";
1752
+ while (!chats && current()) {
1753
+ ui.notify(`${stage}: ${diagnostic}`, "warning");
1754
+ const action = await ui.select("IM forwarding setup \u2014 Feishu authentication", ["Create a new Feishu app (browser authorization)", "Use an existing app (configure in official terminal)", "Recheck connection", "Enter chat ID manually (readiness unverified)", "Cancel"], options);
1755
+ if (!current() || !action || action === "Cancel") return;
1756
+ if (action === "Enter chat ID manually (readiness unverified)") break;
1757
+ if (action === "Create a new Feishu app (browser authorization)") {
1758
+ if (!await ui.confirm("Create a new Feishu app?", "The default browser will open for authorization. This creates an external Feishu application, not just a sign-in to an existing app. The official CLI will write its configuration and existing profiles may change. Pie does not collect app secrets. Continue?", options) || !current()) return;
1759
+ ui.notify("Authorize only in the official browser flow. Do not enter secrets in Pie. Open the exact URL displayed below; setup is bounded to two minutes.", "info");
1760
+ const result = await run(binary, withProfile(["config", "init", "--new", "--brand", "feishu", ...profileName ? ["--name", profileName] : []]), 12e4, cwd, { signal, allowEmpty: true }, true);
1761
+ if (!current()) return;
1762
+ if (result.error) {
1763
+ ui.notify(`New-app browser authorization: ${setupFailure(result)} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
1764
+ return;
1765
+ }
1766
+ stage = "Authorization command completed, but chat listing failed (check app scopes and membership)";
1767
+ } else if (action === "Use an existing app (configure in official terminal)") {
1768
+ const terminal = deps.terminal?.(binary, cwd);
1769
+ let manualReason = "Automatic terminal launch is unavailable on this system. No terminal was opened.";
1770
+ let completed = false;
1771
+ if (terminal) {
1772
+ if (!await ui.confirm("Open official setup in a separate terminal?", "Pie will open a separate Windows terminal running official lark-cli config init --brand feishu. Choose the existing-app option, NOT create a new app. Official CLI configuration and profiles may change. Enter app ID and secret only in that terminal; never in Pie. Pie does not capture credentials. Continue?", options) || !current()) return;
1773
+ const result = await run(terminal, [], 3e5, cwd, { signal, allowEmpty: true }, false, "Complete official setup in separate terminal. Pie does not capture credentials. Cancel closes the owned setup process tree.");
1774
+ if (!current()) return;
1775
+ completed = !result.error;
1776
+ if (!completed) {
1777
+ manualReason = /^timeout/.test(result.error ?? "") ? "Official terminal setup timed out (five-minute limit)." : "Official terminal setup failed or could not open.";
1778
+ ui.notify(`${manualReason} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
1779
+ }
1780
+ }
1781
+ if (!completed) {
1782
+ const next = await ui.select(`IM forwarding setup \u2014 after official terminal configuration
1783
+ ${manualReason}
1784
+ In your trusted terminal run: lark-cli config init --brand feishu
1785
+ Use your configured official CLI if it is not on PATH. Choose existing app, not create new. Profiles may change. Enter credentials only there, never in Pie.
1786
+ After completing that external step, select Recheck connection.`, ["Recheck connection", "Cancel"], options);
1787
+ if (!current() || next !== "Recheck connection") return;
1788
+ }
1789
+ stage = "Official terminal setup completed, but chat listing failed (check app scopes and membership)";
1790
+ } else if (action !== "Recheck connection") return;
1791
+ chats = await discover();
1792
+ if (!current()) return;
1793
+ }
1794
+ const directLabel = "Direct message a user (private chat)";
1795
+ const manual = "Enter a chat_id manually";
1796
+ const labels = (chats ?? []).map((chat) => `${chat.name} \u2014 ${chat.id}`);
1797
+ let target;
1798
+ while (!target && current()) {
1799
+ const choice = await ui.select("IM forwarding setup \u2014 target (bots cannot list P2P chats; DM is supported)", [directLabel, ...labels, manual, "Cancel"], options);
1800
+ if (!current() || !choice || choice === "Cancel") return;
1801
+ if (choice === directLabel) {
1802
+ const dm = await ui.select("IM forwarding setup \u2014 direct message", ["Detect from a message I send to the bot", "Enter user open_id (ou_...)", "Back"], options);
1803
+ if (!current() || !dm || dm === "Back") continue;
1804
+ if (dm.startsWith("Detect")) {
1805
+ let resumeConsumer;
1806
+ const suspend = deps.suspendEventConsumer ?? io.suspendEventConsumer;
1807
+ try {
1808
+ resumeConsumer = await suspend?.();
1809
+ } catch {
1810
+ }
1811
+ try {
1812
+ ui.notify("In Feishu, open the bot's private chat and send any message (for example hello). Pie will detect that P2P chat; it will not send anything.", "info");
1813
+ let found;
1814
+ const detect = await run(binary, withProfile(["event", "consume", "im.message.receive_v1", "--as", "bot"]), 9e4, cwd, { signal, allowEmpty: true, stdin: "pipe" }, false, "In Feishu, open the bot's private chat and send any message. Waiting up to 90 seconds for the DM chat to be detected; nothing is sent by Pie.", (line) => {
1815
+ const event = parseDirectMessage(line);
1816
+ if (!event) return false;
1817
+ found = event;
1818
+ return true;
1819
+ });
1820
+ if (!current()) return;
1821
+ if (!found) {
1822
+ const bus = /another event bus|already connected/i.test(`${detect.stdout}
1823
+ ${detect.error ?? ""}`);
1824
+ ui.notify(bus ? "No direct message detected: the app's event connection is still occupied (another consumer on this or another machine). Stop it, or bind a different app/profile, then retry." : "No direct message detected. Check the bot event subscription, try again, or enter the user's open_id.", "warning");
1825
+ continue;
1826
+ }
1827
+ target = { chatId: found.chatId, allowUsers: [found.senderId] };
1828
+ break;
1829
+ } finally {
1830
+ try {
1831
+ resumeConsumer?.();
1832
+ } catch {
1833
+ }
1834
+ }
1835
+ }
1836
+ const input = await ui.input("IM forwarding setup \u2014 direct-message user open_id", "ou_\u2026 (not a chat_id)", options);
1837
+ if (!current() || !input?.trim()) continue;
1838
+ const openId = input.trim();
1839
+ if (!/^ou_[A-Za-z0-9_-]+$/.test(openId)) {
1840
+ ui.notify("Invalid user open_id; expected ou_ followed by letters, digits, _ or -. Nothing was saved.", "error");
1841
+ continue;
1842
+ }
1843
+ target = { openId, allowUsers: [openId] };
1844
+ break;
1845
+ }
1846
+ if (choice === manual) {
1847
+ const input = await ui.input("IM forwarding setup \u2014 chat_id", "oc_\u2026", options);
1848
+ if (!current() || !input?.trim()) continue;
1849
+ const chatId = input.trim();
1850
+ if (!/^oc_[A-Za-z0-9_-]+$/.test(chatId)) {
1851
+ ui.notify("Invalid chat_id; expected oc_ followed by letters, digits, _ or -. Nothing was saved.", "error");
1852
+ continue;
1853
+ }
1854
+ target = { chatId };
1855
+ break;
1856
+ }
1857
+ const group = chats?.[labels.indexOf(choice)]?.id;
1858
+ if (group) {
1859
+ target = { chatId: group };
1860
+ break;
1861
+ }
1862
+ }
1863
+ if (!current() || !target) return;
1864
+ const boundAppId = profiles?.find((item) => item.name === profileName)?.appId ?? (profileName ? void 0 : profiles?.find((item) => item.active)?.appId);
1865
+ return { ...previous ?? {}, ...target.openId ? { openId: target.openId, chatId: null } : { chatId: target.chatId, openId: null }, larkCli: executable ?? null, larkCliProfile: profileName ?? null, larkCliAppId: boundAppId ?? null, allowUsers: target.allowUsers ?? previous?.allowUsers ?? null };
1866
+ }
1867
+
1868
+ // builtin/extensions/im-forward/providers/feishu/index.ts
1869
+ function normalizeEvent(line) {
1870
+ let raw;
1871
+ try {
1872
+ raw = JSON.parse(line);
1873
+ } catch {
1874
+ return null;
1875
+ }
1876
+ if (!isRecord(raw) || raw.type !== "im.message.receive_v1" || typeof raw.event_id !== "string" || typeof raw.chat_id !== "string") return null;
1877
+ const id = raw.message_id ?? raw.id;
1878
+ if (typeof id !== "string") return null;
1879
+ return {
1880
+ event_id: raw.event_id,
1881
+ message_id: id,
1882
+ chat_id: raw.chat_id,
1883
+ sender_id: typeof raw.sender_id === "string" ? raw.sender_id : void 0,
1884
+ chatType: raw.chat_type === void 0 ? void 0 : typeof raw.chat_type === "string" ? raw.chat_type : "invalid",
1885
+ text: extractInboundText(raw.content).replace(/\[Image:\s*img_[A-Za-z0-9_-]+\]/g, "").trim(),
1886
+ images: extractImageKeys(raw.content)
1887
+ };
1888
+ }
1889
+ function connect(binary, profile, targetKind = "chat") {
1890
+ const lifetime = new AbortController();
1891
+ const active = /* @__PURE__ */ new Set();
1892
+ const native = (...args) => {
1893
+ const pending = runLarkCli(...args);
1894
+ active.add(pending);
1895
+ void pending.finally(() => active.delete(pending));
1896
+ return pending;
1897
+ };
1898
+ const call = async (args) => {
1899
+ const result = await native(binary, profile ? [...args, "--profile", profile] : args, 3e4, void 0, { signal: lifetime.signal });
1900
+ return result.error ? void 0 : parseLarkCliEnvelope(result.stdout).data;
1901
+ };
1902
+ return {
1903
+ sourcePrefix: "[Feishu]",
1904
+ async dispose() {
1905
+ lifetime.abort();
1906
+ await Promise.allSettled([...active]);
1907
+ },
1908
+ async send(target, text, key, replyTo) {
1909
+ const recipient = targetKind === "user" ? ["--user-id", target] : ["--chat-id", target];
1910
+ const data = await call(replyTo ? ["im", "+messages-reply", "--as", "bot", "--message-id", replyTo, "--markdown", text, "--idempotency-key", key] : ["im", "+messages-send", "--as", "bot", ...recipient, "--markdown", text, "--idempotency-key", key]);
1911
+ return typeof data?.message_id === "string" ? { messageId: data.message_id, threadId: typeof data.thread_id === "string" ? data.thread_id : null } : null;
1912
+ },
1913
+ async acknowledge(id) {
1914
+ await call(["im", "reactions", "create", "--as", "bot", "--params", JSON.stringify({ message_id: id }), "--data", JSON.stringify({ reaction_type: { emoji_type: "THUMBSUP" } })]);
1915
+ },
1916
+ async metadata(id) {
1917
+ const data = await call(["im", "+messages-mget", "--as", "bot", "--message-ids", id]);
1918
+ const message = Array.isArray(data?.messages) ? data.messages[0] : void 0;
1919
+ return isRecord(message) ? { threadId: typeof message.thread_id === "string" && message.thread_id || null, replyTo: typeof message.reply_to === "string" && message.reply_to || null } : null;
1920
+ },
1921
+ async threadMessages(id) {
1922
+ const data = await call(["im", "+threads-messages-list", "--as", "bot", "--thread", id]);
1923
+ return Array.isArray(data?.messages) ? data.messages.flatMap((message) => isRecord(message) && typeof message.message_id === "string" ? [message.message_id] : []) : null;
1924
+ },
1925
+ async downloadImage(messageId, image, index) {
1926
+ const directory = fs4.mkdtempSync(path6.join(os2.tmpdir(), "pie-im-forward-"));
1927
+ const cleanup = () => fs4.rmSync(directory, { recursive: true, force: true });
1928
+ try {
1929
+ const name = `image-${index + 1}.png`;
1930
+ const result = await native(binary, profile ? ["im", "+messages-resources-download", "--as", "bot", "--message-id", messageId, "--file-key", image, "--type", "image", "--output", name, "--profile", profile] : ["im", "+messages-resources-download", "--as", "bot", "--message-id", messageId, "--file-key", image, "--type", "image", "--output", name], 3e4, directory, { signal: lifetime.signal });
1931
+ if (result.error || !parseLarkCliEnvelope(result.stdout).ok) throw new Error("Image download failed");
1932
+ let target = path6.join(directory, name);
1933
+ if (fs4.statSync(target).size > 10 * 1024 * 1024) throw new Error("Image exceeds 10 MiB");
1934
+ const detected = readImageFormat(target);
1935
+ if (detected.ext === ".bin") throw new Error("Unsupported image format");
1936
+ if (detected.ext !== ".png") {
1937
+ const corrected = path6.join(directory, `image-${index + 1}${detected.ext}`);
1938
+ fs4.renameSync(target, corrected);
1939
+ target = corrected;
1940
+ }
1941
+ return { file: { path: target, displayName: `feishu-image-${index + 1}${detected.ext}` }, cleanup };
1942
+ } catch (error) {
1943
+ cleanup();
1944
+ throw error;
1945
+ }
1946
+ },
1947
+ consume(callbacks) {
1948
+ let stopped = false;
1949
+ let closed = false;
1950
+ let failed = false;
1951
+ let lastErrorLine = "";
1952
+ const child = spawn4(binary.command, [...binary.prefix, "event", "consume", "im.message.receive_v1", "--as", "bot", ...profile ? ["--profile", profile] : []], { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, detached: process.platform !== "win32" });
1953
+ const fail = (reason) => {
1954
+ if (!failed && !stopped) {
1955
+ failed = true;
1956
+ callbacks.failure(lastErrorLine ? `${reason} \u2014 ${lastErrorLine}` : reason);
1957
+ }
1958
+ };
1959
+ child.stdin?.on("error", () => void 0);
1960
+ const output = lineReader((line) => {
1961
+ if (closed) return;
1962
+ const event = normalizeEvent(line);
1963
+ if (event) callbacks.event(event);
1964
+ });
1965
+ const safeErrorPatterns = [
1966
+ /another event bus is already connected/i,
1967
+ /no active profile/i,
1968
+ /not configured/i,
1969
+ /authorization|unauthorized|token/i,
1970
+ /permission/i,
1971
+ /connect|network|dns|timeout/i
1972
+ ];
1973
+ const errors = lineReader((line) => {
1974
+ const text = line.trim();
1975
+ if (!text) return;
1976
+ if (text.startsWith("[event] ready")) {
1977
+ if (!failed && !stopped) callbacks.ready();
1978
+ return;
1979
+ }
1980
+ if (safeErrorPatterns.some((pattern) => pattern.test(text)) && text.length <= 200) lastErrorLine = text;
1981
+ }, 4096);
1982
+ child.stdout?.on("data", output.write);
1983
+ child.stdout?.on("end", output.end);
1984
+ child.stderr?.on("data", errors.write);
1985
+ child.stderr?.on("end", errors.end);
1986
+ child.on("error", () => fail("Feishu consumer spawn failed"));
1987
+ child.on("exit", (code, signal) => fail(`Feishu consumer code=${code} signal=${signal}`));
1988
+ child.on("close", (code, signal) => {
1989
+ closed = true;
1990
+ fail(`Feishu consumer code=${code} signal=${signal}`);
1991
+ });
1992
+ return { async stop() {
1993
+ stopped = true;
1994
+ if (!closed) await shutdownConsumer(child);
1995
+ } };
1996
+ }
1997
+ };
1998
+ }
1999
+ var feishuProvider = {
2000
+ id: "feishu",
2001
+ label: "Feishu (Lark)",
2002
+ resolve(raw) {
2003
+ const chatId = typeof raw.chatId === "string" ? raw.chatId.trim() : "";
2004
+ const openId = typeof raw.openId === "string" ? raw.openId.trim() : "";
2005
+ const targetKind = /^oc_[A-Za-z0-9_-]+$/.test(chatId) ? "chat" : /^ou_[A-Za-z0-9_-]+$/.test(openId) ? "user" : void 0;
2006
+ if (!targetKind) return null;
2007
+ const allowed = Array.isArray(raw.allowUsers) ? raw.allowUsers.filter((id) => typeof id === "string" && !!id.trim()) : void 0;
2008
+ const appId = typeof raw.larkCliAppId === "string" && raw.larkCliAppId.trim() ? raw.larkCliAppId.trim() : void 0;
2009
+ return { ...raw, targetId: targetKind === "user" ? openId : chatId, targetKind, allowUsers: allowed?.length ? allowed : void 0, ...appId ? { appId } : {} };
2010
+ },
2011
+ connect(settings, cwd) {
2012
+ const binary = resolveLaunchSpec(typeof settings.larkCli === "string" ? settings.larkCli : void 0, cwd);
2013
+ const profile = typeof settings.larkCliProfile === "string" && settings.larkCliProfile.trim() ? settings.larkCliProfile.trim() : void 0;
2014
+ return binary ? connect(binary, profile, settings.targetKind) : null;
2015
+ },
2016
+ setup: setupFeishu
2017
+ };
2018
+
2019
+ // builtin/extensions/im-forward/index.ts
2020
+ var implementedProviders = [feishuProvider];
2021
+ function imForward(ctx) {
2022
+ initializeImForward(ctx);
2023
+ }
2024
+ function initializeImForward(ctx, providers = implementedProviders) {
2025
+ if (!ctx.hasUI) return;
2026
+ const dir = resolveConfigDir();
2027
+ let disposed = false;
2028
+ let session = null;
2029
+ let setupAbort;
2030
+ let setupFlight;
2031
+ let activeConfig;
2032
+ let runtimeCleanup;
2033
+ let suspendEventConsumer;
2034
+ let toggle;
2035
+ let status;
2036
+ let handlers = /* @__PURE__ */ new Map();
2037
+ const events = ["terminal:input", "message:end", "turn:failed", "turn:cancelled", "session:changed"];
2038
+ for (const name of events) ctx.on(name, (event) => {
2039
+ if (disposed) return;
2040
+ if (name === "session:changed") {
2041
+ session = event.sessionId;
2042
+ setupAbort?.abort();
2043
+ }
2044
+ return handlers.get(name)?.(event);
2045
+ });
2046
+ let warnedMismatch = false;
2047
+ function liveEnabled(config) {
2048
+ if (disposed) return false;
2049
+ const live = readLiveConfig(ctx.cwd, dir);
2050
+ if (live.problems.length || !live.config || configurationIdentity(live.config) !== configurationIdentity(config)) {
2051
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2052
+ if (!warnedMismatch) {
2053
+ warnedMismatch = true;
2054
+ ctx.ui.notify("IM forwarding configuration changed or cannot be read. Forwarding is paused; run /im-forward setup.", "warning");
2055
+ }
2056
+ return null;
2057
+ }
2058
+ return live.config.enabled;
2059
+ }
2060
+ function activate(config) {
2061
+ if (disposed) return;
2062
+ const provider = providers.find((item) => item.id === config.provider);
2063
+ if (!provider) {
2064
+ ctx.ui.notify(`IM forwarding: unsupported provider "${config.provider}". Run /im-forward setup.`, "error");
2065
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2066
+ return;
2067
+ }
2068
+ try {
2069
+ const settings = provider.resolve(config.settings);
2070
+ if (!settings) {
2071
+ ctx.ui.notify(`IM forwarding: ${provider.label} target/settings are incomplete. Use Ctrl+I or /im-forward setup.`, "warning");
2072
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2073
+ return;
2074
+ }
2075
+ const connection = provider.connect(settings, ctx.cwd);
2076
+ if (!connection) {
2077
+ ctx.ui.notify(`IM forwarding: ${provider.label} connection could not be prepared. Use Ctrl+I or /im-forward setup to check provider dependencies and configuration.`, "warning");
2078
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2079
+ return;
2080
+ }
2081
+ activeConfig = config;
2082
+ warnedMismatch = false;
2083
+ const bridge = Object.create(ctx);
2084
+ bridge.on = (name, handler) => {
2085
+ handlers.set(name, handler);
2086
+ };
2087
+ bridge.registerShortcut = (_key, shortcut) => {
2088
+ toggle = shortcut.handler;
2089
+ };
2090
+ bridge.registerCommand = (command) => {
2091
+ status = command.handler;
2092
+ };
2093
+ bridge.registerCleanup = (cleanup) => {
2094
+ runtimeCleanup = async () => {
2095
+ await cleanup("runtime_switch", AbortSignal.abort());
2096
+ };
2097
+ return () => {
2098
+ };
2099
+ };
2100
+ createRuntime(bridge, { enabled: config.enabled, provider: config.provider, label: provider.label, targetId: settings.targetId, targetKind: settings.targetKind, allowUsers: settings.allowUsers, retryBackoffMs: config.retryBackoffMs, ...settings.appId ? { appId: settings.appId } : {} }, connection, () => liveEnabled(config), (next) => {
2101
+ const latest = readLiveConfig(ctx.cwd, dir);
2102
+ if (latest.problems.length || !latest.config || configurationIdentity(latest.config) !== configurationIdentity(config)) throw new Error("Configuration changed; setup required");
2103
+ writeConfig(ctx.cwd, dir, { enabled: next });
2104
+ });
2105
+ if (session) handlers.get("session:changed")?.({ type: "session:changed", sessionId: session });
2106
+ ctx.__forceInboundRetriesForTest = bridge.__forceInboundRetriesForTest;
2107
+ suspendEventConsumer = bridge.suspendEventConsumer?.bind(bridge);
2108
+ } catch {
2109
+ ctx.ui.notify(`IM forwarding: ${provider.id} initialization failed; check provider configuration/authentication.`, "error");
2110
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2111
+ }
2112
+ }
2113
+ async function retire() {
2114
+ handlers = /* @__PURE__ */ new Map();
2115
+ toggle = void 0;
2116
+ status = void 0;
2117
+ activeConfig = void 0;
2118
+ suspendEventConsumer = void 0;
2119
+ const cleanup = runtimeCleanup;
2120
+ runtimeCleanup = void 0;
2121
+ ctx.ui.setStatus?.("im-forward", void 0);
2122
+ try {
2123
+ await cleanup?.();
2124
+ } catch {
2125
+ ctx.log("im-forward: runtime cleanup failed");
2126
+ }
2127
+ }
2128
+ function setup(intent = "first-run") {
2129
+ if (setupFlight) return setupFlight;
2130
+ if (disposed) return Promise.resolve();
2131
+ const controller = new AbortController();
2132
+ setupAbort = controller;
2133
+ const setupScope = ctx.beginUserInput?.(() => !disposed && !controller.signal.aborted);
2134
+ controller.signal.addEventListener("abort", () => setupScope?.dispose(), { once: true });
2135
+ const current = () => {
2136
+ if (setupScope && !setupScope.isCurrent()) controller.abort();
2137
+ return !disposed && !controller.signal.aborted;
2138
+ };
2139
+ setupFlight = (async () => {
2140
+ try {
2141
+ const config = await runSetup(ctx, dir, providers, controller.signal, current, intent, suspendEventConsumer);
2142
+ if (!config || disposed) return;
2143
+ await retire();
2144
+ if (!disposed) {
2145
+ const latest = resolveConfig(ctx.cwd, dir);
2146
+ if (latest.problems.length || !latest.config) {
2147
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2148
+ ctx.ui.notify("IM forwarding configuration cannot be read; run /im-forward setup.", "error");
2149
+ } else activate(latest.config);
2150
+ }
2151
+ } catch {
2152
+ if (!disposed && !controller.signal.aborted) ctx.ui.notify("IM forwarding setup could not complete; check configuration path permissions and provider authentication.", "error");
2153
+ } finally {
2154
+ setupScope?.dispose();
2155
+ setupFlight = void 0;
2156
+ if (setupAbort === controller) setupAbort = void 0;
2157
+ }
2158
+ })();
2159
+ return setupFlight;
2160
+ }
2161
+ async function toggleNow(commandCtx) {
2162
+ if (!toggle || !activeConfig) {
2163
+ await setup();
2164
+ return;
2165
+ }
2166
+ const latest = resolveConfig(ctx.cwd, dir);
2167
+ if (!latest.config || latest.problems.length || configurationIdentity(latest.config) !== configurationIdentity(activeConfig)) {
2168
+ commandCtx.ui.notify("IM forwarding configuration changed. Run /im-forward setup before forwarding.", "warning");
2169
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2170
+ return;
2171
+ }
2172
+ await toggle(commandCtx);
2173
+ }
2174
+ function showStatus(commandCtx) {
2175
+ if (status) status(commandCtx);
2176
+ else commandCtx.ui.notify("IM forwarding is not configured or unavailable. Use Ctrl+I or /im-forward setup.", "info");
2177
+ }
2178
+ async function clearConfiguration(commandCtx) {
2179
+ setupAbort?.abort();
2180
+ try {
2181
+ const removed = await clearImConfig(ctx.cwd, dir, (message) => commandCtx.ui.confirm("Clear IM forwarding configuration", `${message}
2182
+
2183
+ This does not remove the app credentials lark-cli itself stores; re-running setup may reuse the previous app.`));
2184
+ if (disposed) return;
2185
+ if (!removed) {
2186
+ commandCtx.ui.notify(hasConfigLayers(ctx.cwd, dir) ? "IM forwarding configuration was not cleared." : "No IM forwarding configuration files exist.", "info");
2187
+ return;
2188
+ }
2189
+ await retire();
2190
+ commandCtx.ui.notify("IM forwarding configuration cleared. Use /im-forward setup to configure again.", "info");
2191
+ } catch (error) {
2192
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2193
+ commandCtx.ui.notify(error.message || "IM forwarding configuration could not be cleared.", "error");
2194
+ }
2195
+ }
2196
+ ctx.registerShortcut("ctrl+i", { description: "Set up or toggle IM forwarding", handler: () => toggleNow(ctx) });
2197
+ ctx.registerCommand({ path: ["im-forward", "status"], description: "Show IM forwarding status", handler: async (commandCtx) => {
2198
+ if (!disposed) showStatus(commandCtx);
2199
+ } });
2200
+ ctx.registerCommand({ path: ["im-forward", "switch-app"], description: "Rebind the configured platform to a different app/account", handler: async () => {
2201
+ if (!disposed) await setup("switch-app");
2202
+ } });
2203
+ ctx.registerCommand({ path: ["im-forward", "setup"], description: "Run the IM forwarding setup wizard", handler: async () => {
2204
+ if (!disposed) await setup();
2205
+ } });
2206
+ ctx.registerCommand({ path: ["im-forward", "toggle"], description: "Toggle IM forwarding on/off (same as Ctrl+I)", handler: async (commandCtx) => {
2207
+ if (!disposed) await toggleNow(commandCtx);
2208
+ } });
2209
+ ctx.registerCommand({ path: ["im-forward", "clear"], description: "Remove IM forwarding configuration (with confirmation)", handler: async (commandCtx) => {
2210
+ if (!disposed) await clearConfiguration(commandCtx);
2211
+ } });
2212
+ ctx.registerCommand({ name: "im-forward", path: ["im-forward"], description: "IM forwarding menu (status/setup/toggle/clear)", handler: async (commandCtx, args) => {
2213
+ if (disposed) return;
2214
+ const arg = args?.trim();
2215
+ if (arg === "status") {
2216
+ showStatus(commandCtx);
2217
+ return;
2218
+ }
2219
+ if (arg === "setup") {
2220
+ await setup();
2221
+ return;
2222
+ }
2223
+ if (arg === "switch-app") {
2224
+ await setup("switch-app");
2225
+ return;
2226
+ }
2227
+ if (arg === "toggle") {
2228
+ await toggleNow(commandCtx);
2229
+ return;
2230
+ }
2231
+ if (arg === "clear") {
2232
+ await clearConfiguration(commandCtx);
2233
+ return;
2234
+ }
2235
+ if (arg) {
2236
+ commandCtx.ui.notify("Usage: /im-forward [status|setup|switch-app|toggle|clear]; bare /im-forward opens the menu", "info");
2237
+ return;
2238
+ }
2239
+ const options = toggle && activeConfig ? ["Status", "Setup\u2026", "Switch app\u2026", "Toggle forwarding on/off", "Clear configuration\u2026"] : hasConfigLayers(ctx.cwd, dir) ? ["Status", "Setup\u2026", "Switch app\u2026", "Clear configuration\u2026"] : ["Setup\u2026"];
2240
+ const choice = await commandCtx.ui.select("IM forwarding", options);
2241
+ if (disposed || !choice) return;
2242
+ if (choice === "Status") showStatus(commandCtx);
2243
+ else if (choice === "Setup\u2026") await setup();
2244
+ else if (choice === "Switch app\u2026") await setup("switch-app");
2245
+ else if (choice === "Toggle forwarding on/off") await toggleNow(commandCtx);
2246
+ else if (choice === "Clear configuration\u2026") await clearConfiguration(commandCtx);
2247
+ } });
2248
+ ctx.registerCleanup?.(async () => {
2249
+ disposed = true;
2250
+ setupAbort?.abort();
2251
+ await retire();
2252
+ ctx.ui.setStatus?.("im-forward", void 0);
2253
+ });
2254
+ const initial = resolveConfig(ctx.cwd, dir);
2255
+ if (initial.problems.length) {
2256
+ ctx.ui.notify(initial.problems.join("\n"), "error");
2257
+ ctx.ui.setStatus?.("im-forward", "[IM!]");
2258
+ } else if (initial.config) activate(initial.config);
965
2259
  }
966
2260
  export {
967
2261
  assistantForwardText,
968
2262
  composeForwardBody,
969
2263
  imForward as default,
970
2264
  detectImageMime,
2265
+ estimatePostRequestBytes,
971
2266
  extractImageKey,
972
2267
  extractImageKeys,
973
2268
  extractInboundText,
974
2269
  failureReason,
2270
+ initializeImForward,
975
2271
  inputHeaderLine,
976
2272
  parseLarkCliEnvelope,
2273
+ readLiveConfig,
977
2274
  readLiveEnabled,
978
2275
  resolveImForwardConfig,
979
- sessionLabel
2276
+ sessionLabel,
2277
+ truncateMarkdown
980
2278
  };