@modelzen/feishu-codex-bridge 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +493 -39
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -538,7 +538,7 @@ async function spawnExecProvider(pc, ref) {
538
538
  const timeoutMs = pc.noOutputTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
539
539
  const maxOutput = pc.maxOutputBytes ?? DEFAULT_EXEC_MAX_OUTPUT;
540
540
  const providerName = ref.provider ?? DEFAULT_PROVIDER;
541
- return new Promise((resolve5, reject) => {
541
+ return new Promise((resolve6, reject) => {
542
542
  const env = {};
543
543
  if (pc.passEnv) for (const k of pc.passEnv) {
544
544
  const v = process.env[k];
@@ -583,7 +583,7 @@ async function spawnExecProvider(pc, ref) {
583
583
  try {
584
584
  const parsed = JSON.parse(stdout);
585
585
  const value = parsed.values?.[ref.id];
586
- if (typeof value === "string") return resolve5(value);
586
+ if (typeof value === "string") return resolve6(value);
587
587
  const err = parsed.errors?.[ref.id]?.message;
588
588
  reject(new Error(`exec provider did not return secret for ${ref.id}${err ? `: ${err}` : ""}`));
589
589
  } catch (err) {
@@ -680,6 +680,30 @@ var COMMENT_SCOPES = [
680
680
  "wiki:wiki:readonly"
681
681
  ];
682
682
  var GRANT_SCOPES = [...REQUIRED_SCOPES, ...COMMENT_SCOPES];
683
+ var SCOPE_LABELS = {
684
+ "im:message.group_at_msg:readonly": "\u63A5\u6536\u7FA4\u91CC @\u673A\u5668\u4EBA \u7684\u6D88\u606F",
685
+ "im:message.group_msg": "\u63A5\u6536\u7FA4\u5185\u6240\u6709\u6D88\u606F\uFF08\u514D@\uFF09",
686
+ "im:message.p2p_msg:readonly": "\u63A5\u6536\u79C1\u804A\u6D88\u606F\uFF08\u7BA1\u7406\u53F0\uFF09",
687
+ "im:message:send_as_bot": "\u53D1\u9001\u6D88\u606F / \u5361\u7247",
688
+ "im:message.pins:write_only": "\u7F6E\u9876\u6D88\u606F\u5230\u7FA4 Pin",
689
+ "im:message.reactions:write_only": "\u6D88\u606F\u8868\u60C5\u56DE\u590D\uFF08\u8FD0\u884C\u72B6\u6001\uFF09",
690
+ "im:resource": "\u56FE\u7247 / \u6587\u4EF6\u4E0A\u4F20\u4E0E\u4E0B\u8F7D",
691
+ "im:chat:create": "\u521B\u5EFA\u9879\u76EE\u7FA4",
692
+ "im:chat:update": "\u8F6C\u79FB\u7FA4\u4E3B\uFF08\u89E3\u7ED1\u65F6\uFF09",
693
+ "im:chat.managers:write_only": "\u8BBE\u7F6E\u7FA4\u7BA1\u7406\u5458",
694
+ "im:chat.announcement:read": "\u8BFB\u53D6\u7FA4\u516C\u544A",
695
+ "im:chat.announcement:write_only": "\u7F16\u8F91\u7FA4\u516C\u544A",
696
+ "im:chat.top_notice:write_only": "\u7F6E\u9876\u7FA4\u516C\u544A\u6A2A\u5E45",
697
+ "im:chat.tabs:write_only": "\u6DFB\u52A0\u7FA4\u6807\u7B7E\u9875",
698
+ "cardkit:card:write": "\u4EA4\u4E92\u6309\u94AE\u5361\u7247",
699
+ "docs:document.comment:read": "\u8BFB\u53D6\u6587\u6863\u8BC4\u8BBA",
700
+ "docs:document.comment:create": "\u53D1\u8868\u6587\u6863\u8BC4\u8BBA\u56DE\u590D",
701
+ "wiki:wiki:readonly": "\u8BFB\u53D6\u77E5\u8BC6\u5E93\u8282\u70B9"
702
+ };
703
+ function labelScope(scope) {
704
+ const label = SCOPE_LABELS[scope];
705
+ return label ? `${label}\uFF08${scope}\uFF09` : scope;
706
+ }
683
707
  var HOSTS = {
684
708
  feishu: "open.feishu.cn",
685
709
  lark: "open.larksuite.com"
@@ -1112,7 +1136,7 @@ var AsyncQueue = class {
1112
1136
  continue;
1113
1137
  }
1114
1138
  if (this.closed) return;
1115
- const next = await new Promise((resolve5) => this.waiters.push(resolve5));
1139
+ const next = await new Promise((resolve6) => this.waiters.push(resolve6));
1116
1140
  if (next.done) return;
1117
1141
  yield next.value;
1118
1142
  }
@@ -1163,8 +1187,8 @@ var AppServerClient = class {
1163
1187
  const id = ++this.nextId;
1164
1188
  const payload = `${JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} })}
1165
1189
  `;
1166
- return new Promise((resolve5, reject) => {
1167
- this.pending.set(id, { resolve: resolve5, reject });
1190
+ return new Promise((resolve6, reject) => {
1191
+ this.pending.set(id, { resolve: resolve6, reject });
1168
1192
  this.child.stdin.write(payload, (err) => {
1169
1193
  if (err) {
1170
1194
  this.pending.delete(id);
@@ -1188,14 +1212,14 @@ var AppServerClient = class {
1188
1212
  const child = this.child;
1189
1213
  if (!child || child.exitCode !== null) return;
1190
1214
  child.kill("SIGTERM");
1191
- await new Promise((resolve5) => {
1215
+ await new Promise((resolve6) => {
1192
1216
  const t = setTimeout(() => {
1193
1217
  if (child.exitCode === null) child.kill("SIGKILL");
1194
- resolve5();
1218
+ resolve6();
1195
1219
  }, graceMs);
1196
1220
  child.once("exit", () => {
1197
1221
  clearTimeout(t);
1198
- resolve5();
1222
+ resolve6();
1199
1223
  });
1200
1224
  });
1201
1225
  }
@@ -1312,14 +1336,28 @@ function mapItemComplete(item) {
1312
1336
  // src/agent/codex-appserver/backend.ts
1313
1337
  var APPROVAL_POLICY = "never";
1314
1338
  var SANDBOX = "danger-full-access";
1339
+ var BRIDGE_DEVELOPER_INSTRUCTIONS = [
1340
+ "\u4F60\u73B0\u5728\u901A\u8FC7\u300C\u98DE\u4E66\u6865\u300D\u4E0E\u7528\u6237\u5BF9\u8BDD\uFF1A\u4F60\u7684\u56DE\u590D\u4F1A\u88AB\u6E32\u67D3\u6210\u98DE\u4E66\u6D88\u606F\u3002\u8BF7\u9075\u5B88\u4E24\u6761\u8F93\u51FA\u7EA6\u5B9A\u3002",
1341
+ "",
1342
+ "1) \u56FE\u7247\uFF1A\u8981\u914D\u56FE\u65F6\uFF0C\u7528\u6807\u51C6 Markdown \u56FE\u7247\u8BED\u6CD5 ![\u8BF4\u660E](\u8DEF\u5F84) \u5F15\u7528\u4E00\u4E2A\u3010\u771F\u5B9E\u5B58\u5728\u3011\u7684\u56FE\u7247\uFF0C",
1343
+ "\u98DE\u4E66\u6865\u4F1A\u81EA\u52A8\u4E0A\u4F20\u5E76\u5728\u98DE\u4E66\u91CC\u6E32\u67D3\u3002\u8DEF\u5F84\u53EF\u4EE5\u662F\u76F8\u5BF9\u5F53\u524D\u5DE5\u4F5C\u76EE\u5F55\u7684\u76F8\u5BF9\u8DEF\u5F84\u3001\u5DE5\u4F5C\u76EE\u5F55\u5185\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF0C",
1344
+ "\u6216\u4E00\u4E2A http(s) \u56FE\u7247 URL\u3002\u7EDD\u4E0D\u8981\u7F16\u9020\u4E0D\u5B58\u5728\u7684\u56FE\u7247\u5360\u4F4D\uFF08\u4F8B\u5982\u5199 ![\u7BA1\u7406\u53F0\u622A\u56FE] \u5374\u6CA1\u6709\u5BF9\u5E94\u6587\u4EF6\uFF09\u2014\u2014",
1345
+ "\u6CA1\u6709\u771F\u5B9E\u56FE\u7247\u5C31\u4E0D\u8981\u5199\u56FE\u7247\u8BED\u6CD5\u3002",
1346
+ "",
1347
+ "2) \u5361\u7247\uFF1A\u4EC5\u5F53\u7528\u6237\u660E\u786E\u8981\u6C42\u300C\u7528\u5361\u7247\u56DE\u590D / \u505A\u6210\u98DE\u4E66\u5361\u7247 / \u5361\u7247\u5F62\u5F0F\u5C55\u793A / changelog \u5361\u7247\u300D\u4E4B\u7C7B\u65F6\uFF0C",
1348
+ "\u628A\u8981\u5C55\u793A\u7684\u5185\u5BB9\u5305\u8FDB\u4E00\u4E2A ```feishu-card \u4EE3\u7801\u5757\uFF0C\u5757\u5185\u7528 Markdown \u4E66\u5199\uFF1A",
1349
+ "\u9996\u884C\u7528 `# \u6807\u9898` \u4F5C\u4E3A\u5361\u7247\u6807\u9898\u680F\uFF1B\u7528 `---` \u4F5C\u5206\u9694\u7EBF\uFF1B\u7528 `> \u6587\u5B57` \u4F5C\u7070\u8272\u6CE8\u811A\uFF1B",
1350
+ "`**\u7C97\u4F53**`\u3001\u5217\u8868\u3001\u94FE\u63A5\u7167\u5E38\u4F7F\u7528\uFF1B\u914D\u56FE\u540C\u6837\u7528 ![\u8BF4\u660E](\u771F\u5B9E\u8DEF\u5F84)\u3002",
1351
+ "\u4E0D\u8981\u624B\u5199\u98DE\u4E66\u5361\u7247\u7684 JSON\u3002\u666E\u901A\u95EE\u7B54\u6B63\u5E38\u56DE\u590D\u5373\u53EF\uFF0C\u53EA\u6709\u7528\u6237\u8981\u5361\u7247\u65F6\u624D\u7528 ```feishu-card \u4EE3\u7801\u5757\u3002"
1352
+ ].join("\n");
1315
1353
  var READ_HISTORY_TIMEOUT_MS = 2e4;
1316
1354
  function withDeadline(p, ms, label) {
1317
- return new Promise((resolve5, reject) => {
1355
+ return new Promise((resolve6, reject) => {
1318
1356
  const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
1319
1357
  p.then(
1320
1358
  (v) => {
1321
1359
  clearTimeout(t);
1322
- resolve5(v);
1360
+ resolve6(v);
1323
1361
  },
1324
1362
  (e) => {
1325
1363
  clearTimeout(t);
@@ -1359,11 +1397,11 @@ var CodexThread = class {
1359
1397
  if (self.model) params.model = self.model;
1360
1398
  if (self.effort) params.effort = self.effort;
1361
1399
  let startError;
1362
- const startFailed = new Promise((resolve5) => {
1400
+ const startFailed = new Promise((resolve6) => {
1363
1401
  self.client.request("turn/start", params).then(void 0, (err) => {
1364
1402
  startError = err instanceof Error ? err : new Error(String(err));
1365
1403
  log.fail("agent", startError, { phase: "turn/start" });
1366
- resolve5("start-failed");
1404
+ resolve6("start-failed");
1367
1405
  });
1368
1406
  });
1369
1407
  const stream2 = self.client.stream()[Symbol.asyncIterator]();
@@ -1487,6 +1525,7 @@ var CodexAppServerBackend = class {
1487
1525
  cwd: opts.cwd,
1488
1526
  approvalPolicy: APPROVAL_POLICY,
1489
1527
  sandbox: SANDBOX,
1528
+ developerInstructions: BRIDGE_DEVELOPER_INSTRUCTIONS,
1490
1529
  ...opts.model ? { model: opts.model } : {}
1491
1530
  });
1492
1531
  return new CodexThread(client, res.thread.id, opts.model, opts.effort);
@@ -1498,6 +1537,7 @@ var CodexAppServerBackend = class {
1498
1537
  cwd: opts.cwd,
1499
1538
  approvalPolicy: APPROVAL_POLICY,
1500
1539
  sandbox: SANDBOX,
1540
+ developerInstructions: BRIDGE_DEVELOPER_INSTRUCTIONS,
1501
1541
  ...opts.model ? { model: opts.model } : {}
1502
1542
  });
1503
1543
  return new CodexThread(client, res.thread.id, opts.model, opts.effort);
@@ -1928,6 +1968,15 @@ function card(elements, opts = {}) {
1928
1968
  function md(content) {
1929
1969
  return { tag: "markdown", content };
1930
1970
  }
1971
+ function image(imgKey, alt = "") {
1972
+ return {
1973
+ tag: "img",
1974
+ img_key: imgKey,
1975
+ alt: { tag: "plain_text", content: alt },
1976
+ mode: "fit_horizontal",
1977
+ preview: true
1978
+ };
1979
+ }
1931
1980
  function note(content) {
1932
1981
  return { tag: "div", text: { tag: "lark_md", content, text_size: "notation", text_color: "grey" } };
1933
1982
  }
@@ -2337,6 +2386,92 @@ function truncateTail(s, n) {
2337
2386
  return t.length > n ? `\u2026${t.slice(t.length - n)}` : t;
2338
2387
  }
2339
2388
 
2389
+ // src/card/markdown-render.ts
2390
+ var NO_IMAGES = /* @__PURE__ */ new Map();
2391
+ var IMG_RE = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
2392
+ var FENCE_RE = /```feishu-card[^\n]*\n([\s\S]*?)```/g;
2393
+ function cleanSrc(raw) {
2394
+ let s = raw.trim();
2395
+ if (s.startsWith("<") && s.endsWith(">")) s = s.slice(1, -1).trim();
2396
+ return s;
2397
+ }
2398
+ function extractCardFences(text) {
2399
+ const fences = [];
2400
+ const re = new RegExp(FENCE_RE.source, "g");
2401
+ const stripped = text.replace(re, (_full, inner) => {
2402
+ fences.push(inner.trim());
2403
+ return "";
2404
+ });
2405
+ return { fences, stripped };
2406
+ }
2407
+ function renderRichText(text, images = NO_IMAGES) {
2408
+ const body = extractCardFences(text).stripped;
2409
+ if (!body.includes("![")) {
2410
+ const t = body.trim();
2411
+ return t ? [md(t)] : [];
2412
+ }
2413
+ const els = [];
2414
+ let buf = "";
2415
+ const flush = () => {
2416
+ const t = buf.trim();
2417
+ if (t) els.push(md(t));
2418
+ buf = "";
2419
+ };
2420
+ const re = new RegExp(IMG_RE.source, "g");
2421
+ let last = 0;
2422
+ let m;
2423
+ while ((m = re.exec(body)) !== null) {
2424
+ buf += body.slice(last, m.index);
2425
+ const alt = m[1] ?? "";
2426
+ const src = cleanSrc(m[2] ?? "");
2427
+ const key = images.get(src);
2428
+ if (key) {
2429
+ flush();
2430
+ els.push(image(key, alt));
2431
+ } else {
2432
+ buf += m[0];
2433
+ }
2434
+ last = m.index + m[0].length;
2435
+ }
2436
+ buf += body.slice(last);
2437
+ flush();
2438
+ return els;
2439
+ }
2440
+ function buildCleanCard(fenceMarkdown, images = NO_IMAGES, template = "blue") {
2441
+ const lines = fenceMarkdown.split("\n");
2442
+ let start = 0;
2443
+ while (start < lines.length && lines[start]?.trim() === "") start++;
2444
+ const headingMatch = lines[start]?.match(/^#{1,6}\s+(.+?)\s*$/);
2445
+ const title = headingMatch ? headingMatch[1] : "";
2446
+ if (headingMatch) start++;
2447
+ const bodyMarkdown = lines.slice(start).join("\n").trim();
2448
+ const elements = renderCleanBody(bodyMarkdown, images);
2449
+ const body = elements.length > 0 ? elements : [md(title || "\xAD")];
2450
+ return card(body, {
2451
+ ...title ? { header: { title, template } } : {},
2452
+ summary: title || "\u5361\u7247"
2453
+ });
2454
+ }
2455
+ function renderCleanBody(bodyMarkdown, images) {
2456
+ const out = [];
2457
+ for (const raw of bodyMarkdown.split(/\n{2,}/)) {
2458
+ const block = raw.trim();
2459
+ if (!block) continue;
2460
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) {
2461
+ out.push(hr());
2462
+ continue;
2463
+ }
2464
+ const blockLines = block.split("\n");
2465
+ if (blockLines.every((l) => l.trim() === "" || /^\s*>\s?/.test(l))) {
2466
+ const noteText = blockLines.map((l) => l.replace(/^\s*>\s?/, "")).join("\n").trim();
2467
+ if (noteText) out.push(note(noteText));
2468
+ continue;
2469
+ }
2470
+ out.push(...renderRichText(block, images));
2471
+ }
2472
+ return out;
2473
+ }
2474
+
2340
2475
  // src/card/tool-render.ts
2341
2476
  var HEADER_TITLE_MAX = 80;
2342
2477
  var OUTPUT_MAX = 1200;
@@ -2415,7 +2550,7 @@ function renderTerminal(state, rc) {
2415
2550
  })
2416
2551
  );
2417
2552
  }
2418
- if (answer) elements.push(md(answer));
2553
+ if (answer) elements.push(...renderRichText(answer, rc.images));
2419
2554
  if (state.terminal === "interrupted") {
2420
2555
  elements.push(noteMd("_\u23F9 \u5DF2\u88AB\u4E2D\u65AD_"));
2421
2556
  } else if (state.terminal === "idle_timeout") {
@@ -2624,6 +2759,142 @@ var RunCardStream = class {
2624
2759
  }
2625
2760
  };
2626
2761
 
2762
+ // src/card/outbound-images.ts
2763
+ import { readFile as readFile5, stat as stat2 } from "fs/promises";
2764
+ import { extname, isAbsolute, resolve as resolve2, sep } from "path";
2765
+ var MAX_IMAGES = 9;
2766
+ var MAX_BYTES = 10 * 1024 * 1024;
2767
+ var DOWNLOAD_TIMEOUT_MS = 1e4;
2768
+ var ALLOWED_EXT = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "bmp", "ico"]);
2769
+ var cache = /* @__PURE__ */ new Map();
2770
+ var IMG_RE2 = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
2771
+ function cleanSrc2(raw) {
2772
+ let s = raw.trim();
2773
+ if (s.startsWith("<") && s.endsWith(">")) s = s.slice(1, -1).trim();
2774
+ return s;
2775
+ }
2776
+ function imageSources(text) {
2777
+ const out = [];
2778
+ const seen = /* @__PURE__ */ new Set();
2779
+ const re = new RegExp(IMG_RE2.source, "g");
2780
+ let m;
2781
+ while ((m = re.exec(text)) !== null) {
2782
+ const src = cleanSrc2(m[2] ?? "");
2783
+ if (src && !seen.has(src)) {
2784
+ seen.add(src);
2785
+ out.push(src);
2786
+ }
2787
+ }
2788
+ return out;
2789
+ }
2790
+ async function uploadOutboundImages(channel, sources, cwd) {
2791
+ const picked = sources.slice(0, MAX_IMAGES);
2792
+ if (sources.length > picked.length) {
2793
+ log.warn("outbound", "image-cap", { skipped: sources.length - picked.length });
2794
+ }
2795
+ const results = await Promise.all(
2796
+ picked.map(async (src) => {
2797
+ try {
2798
+ return [src, await resolveAndUpload(channel, src, cwd)];
2799
+ } catch (err) {
2800
+ log.warn("outbound", "image-failed", { src: src.slice(0, 80), err: String(err) });
2801
+ return [src, void 0];
2802
+ }
2803
+ })
2804
+ );
2805
+ const out = /* @__PURE__ */ new Map();
2806
+ for (const [src, key] of results) if (key) out.set(src, key);
2807
+ if (out.size > 0) log.info("outbound", "images", { want: sources.length, uploaded: out.size });
2808
+ return out;
2809
+ }
2810
+ async function resolveAndUpload(channel, src, cwd) {
2811
+ const { buffer, cacheKey } = await loadSource(src, cwd);
2812
+ if (!buffer) return void 0;
2813
+ const hit = cache.get(cacheKey);
2814
+ if (hit) return hit;
2815
+ const key = await uploadBuffer(channel, buffer);
2816
+ if (key) cache.set(cacheKey, key);
2817
+ return key;
2818
+ }
2819
+ async function loadSource(src, cwd) {
2820
+ if (/^https?:\/\//i.test(src)) return loadRemote(src);
2821
+ return loadLocal(src, cwd);
2822
+ }
2823
+ async function loadLocal(src, cwd) {
2824
+ const cwdAbs = resolve2(cwd);
2825
+ const abs = isAbsolute(src) ? resolve2(src) : resolve2(cwdAbs, src);
2826
+ if (abs !== cwdAbs && !abs.startsWith(cwdAbs + sep)) {
2827
+ log.warn("outbound", "image-outside-cwd", { src: src.slice(0, 80) });
2828
+ return { cacheKey: `local:${abs}` };
2829
+ }
2830
+ const ext = extname(abs).slice(1).toLowerCase();
2831
+ if (!ALLOWED_EXT.has(ext)) {
2832
+ log.warn("outbound", "image-ext", { ext, src: src.slice(0, 80) });
2833
+ return { cacheKey: `local:${abs}` };
2834
+ }
2835
+ let size;
2836
+ let mtimeMs;
2837
+ try {
2838
+ const st = await stat2(abs);
2839
+ if (!st.isFile()) throw new Error("not a file");
2840
+ size = st.size;
2841
+ mtimeMs = st.mtimeMs;
2842
+ } catch {
2843
+ log.warn("outbound", "image-missing", { src: src.slice(0, 80) });
2844
+ return { cacheKey: `local:${abs}` };
2845
+ }
2846
+ if (size === 0 || size > MAX_BYTES) {
2847
+ log.warn("outbound", "image-size", { size, src: src.slice(0, 80) });
2848
+ return { cacheKey: `local:${abs}:${size}` };
2849
+ }
2850
+ const buffer = await readFile5(abs);
2851
+ return { buffer, cacheKey: `local:${abs}:${mtimeMs}:${size}` };
2852
+ }
2853
+ async function loadRemote(url) {
2854
+ const cacheKey = `url:${url}`;
2855
+ const ctrl = new AbortController();
2856
+ const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
2857
+ try {
2858
+ const res = await fetch(url, { signal: ctrl.signal, redirect: "follow" });
2859
+ if (!res.ok) {
2860
+ log.warn("outbound", "image-http", { url: url.slice(0, 80), status: res.status });
2861
+ return { cacheKey };
2862
+ }
2863
+ const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
2864
+ if (ct && !ct.startsWith("image/")) {
2865
+ log.warn("outbound", "image-ctype", { ct, url: url.slice(0, 80) });
2866
+ return { cacheKey };
2867
+ }
2868
+ const declared = Number(res.headers.get("content-length") ?? 0);
2869
+ if (declared > MAX_BYTES) {
2870
+ log.warn("outbound", "image-size", { declared, url: url.slice(0, 80) });
2871
+ return { cacheKey };
2872
+ }
2873
+ const ab = await res.arrayBuffer();
2874
+ if (ab.byteLength === 0 || ab.byteLength > MAX_BYTES) {
2875
+ log.warn("outbound", "image-size", { size: ab.byteLength, url: url.slice(0, 80) });
2876
+ return { cacheKey };
2877
+ }
2878
+ return { buffer: Buffer.from(ab), cacheKey };
2879
+ } catch (err) {
2880
+ log.warn("outbound", "image-fetch", { url: url.slice(0, 80), err: String(err) });
2881
+ return { cacheKey };
2882
+ } finally {
2883
+ clearTimeout(timer);
2884
+ }
2885
+ }
2886
+ async function uploadBuffer(channel, buffer) {
2887
+ const res = await channel.rawClient.im.v1.image.create({
2888
+ data: { image_type: "message", image: buffer }
2889
+ });
2890
+ const key = res?.image_key ?? res?.data?.image_key;
2891
+ if (!key) {
2892
+ log.warn("outbound", "image-no-key", { res: JSON.stringify(res).slice(0, 120) });
2893
+ return void 0;
2894
+ }
2895
+ return key;
2896
+ }
2897
+
2627
2898
  // src/card/dm-cards.ts
2628
2899
  function openChatUrl(chatId) {
2629
2900
  return `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(chatId)}`;
@@ -2780,8 +3051,9 @@ function scopeDiagnosis(i) {
2780
3051
  return [md("- \u98DE\u4E66\u6743\u9650\uFF1A\u2705 \u5FC5\u9700\u6743\u9650\u5DF2\u5168\u90E8\u5F00\u901A")];
2781
3052
  }
2782
3053
  return [
2783
- md(`- \u98DE\u4E66\u6743\u9650\uFF1A\u274C \u7F3A ${i.missingScopes.length} \u9879 \u2014\u2014 \u5F00\u901A\u524D\u76F8\u5173\u529F\u80FD\uFF08\u6536\u53D1\u6D88\u606F / \u5361\u7247 / \u5EFA\u7FA4\u7B49\uFF09\u4E0D\u53EF\u7528`),
2784
- note(`\u5F85\u5F00\u901A\uFF1A${i.missingScopes.join("\u3000")}`),
3054
+ md(`- \u98DE\u4E66\u6743\u9650\uFF1A\u274C \u7F3A ${i.missingScopes.length} \u9879 \u2014\u2014 \u5F00\u901A\u524D\u76F8\u5173\u529F\u80FD\uFF08\u6536\u53D1\u6D88\u606F / \u5361\u7247 / \u56FE\u7247 / \u5EFA\u7FA4\u7B49\uFF09\u4E0D\u53EF\u7528`),
3055
+ note(`\u5F85\u5F00\u901A\uFF1A
3056
+ ${i.missingScopes.map((s) => `\xB7 ${labelScope(s)}`).join("\n")}`),
2785
3057
  actions([linkButton("\u{1F511} \u4E00\u952E\u53BB\u5F00\u901A\u8FD9\u4E9B\u6743\u9650", i.scopeGrantUrl)])
2786
3058
  ];
2787
3059
  }
@@ -2977,7 +3249,7 @@ function buildGroupSettingsCard(project) {
2977
3249
  // src/service/update.ts
2978
3250
  import { execFile, spawn as spawn5 } from "child_process";
2979
3251
  import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
2980
- import { dirname as dirname6, join as join8, resolve as resolve3 } from "path";
3252
+ import { dirname as dirname6, join as join8, resolve as resolve4 } from "path";
2981
3253
  import { fileURLToPath as fileURLToPath3 } from "url";
2982
3254
  import { promisify } from "util";
2983
3255
 
@@ -2986,7 +3258,7 @@ import { spawn as spawn4, spawnSync } from "child_process";
2986
3258
  import { existsSync as existsSync4 } from "fs";
2987
3259
  import { appendFile, mkdir as mkdir4, rm as rm2, writeFile as writeFile4 } from "fs/promises";
2988
3260
  import { homedir as homedir3, userInfo as userInfo2 } from "os";
2989
- import { dirname as dirname5, join as join7, resolve as resolve2 } from "path";
3261
+ import { dirname as dirname5, join as join7, resolve as resolve3 } from "path";
2990
3262
  import { fileURLToPath as fileURLToPath2 } from "url";
2991
3263
  var LAUNCHD_LABEL = "ai.feishu-codex-bridge.bot";
2992
3264
  function launchAgentPlistPath() {
@@ -3000,7 +3272,7 @@ function serviceStderrPath() {
3000
3272
  }
3001
3273
  function resolveCliBinPath() {
3002
3274
  const distDir = dirname5(fileURLToPath2(import.meta.url));
3003
- return resolve2(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3275
+ return resolve3(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3004
3276
  }
3005
3277
  function escapeXml(value) {
3006
3278
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -3166,7 +3438,7 @@ function getServiceAdapter() {
3166
3438
  var execFileP = promisify(execFile);
3167
3439
  var NPM = process.platform === "win32" ? "npm.cmd" : "npm";
3168
3440
  function pkgRoot() {
3169
- return resolve3(dirname6(fileURLToPath3(import.meta.url)), "..");
3441
+ return resolve4(dirname6(fileURLToPath3(import.meta.url)), "..");
3170
3442
  }
3171
3443
  function pkgJson() {
3172
3444
  try {
@@ -3232,12 +3504,12 @@ async function restartDaemon() {
3232
3504
  }
3233
3505
 
3234
3506
  // src/project/registry.ts
3235
- import { mkdir as mkdir5, readFile as readFile5, rename as rename4, writeFile as writeFile5 } from "fs/promises";
3507
+ import { mkdir as mkdir5, readFile as readFile6, rename as rename4, writeFile as writeFile5 } from "fs/promises";
3236
3508
  import { dirname as dirname7 } from "path";
3237
3509
  var FILE_VERSION2 = 1;
3238
3510
  async function read() {
3239
3511
  try {
3240
- const text = await readFile5(paths.projectsFile, "utf8");
3512
+ const text = await readFile6(paths.projectsFile, "utf8");
3241
3513
  const parsed = JSON.parse(text);
3242
3514
  return Array.isArray(parsed.projects) ? parsed.projects : [];
3243
3515
  } catch (err) {
@@ -3292,7 +3564,7 @@ async function removeProject(name) {
3292
3564
  // src/project/lifecycle.ts
3293
3565
  import { mkdir as mkdir6 } from "fs/promises";
3294
3566
  import { existsSync as existsSync6 } from "fs";
3295
- import { isAbsolute, join as join9, resolve as resolve4 } from "path";
3567
+ import { isAbsolute as isAbsolute2, join as join9, resolve as resolve5 } from "path";
3296
3568
 
3297
3569
  // src/project/git-info.ts
3298
3570
  import { execFile as execFile2 } from "child_process";
@@ -3430,7 +3702,7 @@ async function createProject(channel, input2) {
3430
3702
  let cwd;
3431
3703
  let blank;
3432
3704
  if (input2.existingPath) {
3433
- cwd = isAbsolute(input2.existingPath) ? input2.existingPath : resolve4(input2.existingPath);
3705
+ cwd = isAbsolute2(input2.existingPath) ? input2.existingPath : resolve5(input2.existingPath);
3434
3706
  if (!existsSync6(cwd)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd}`);
3435
3707
  blank = false;
3436
3708
  } else {
@@ -3468,12 +3740,12 @@ async function transferOwnership(channel, chatId, toOpenId) {
3468
3740
  }
3469
3741
 
3470
3742
  // src/bot/session-store.ts
3471
- import { mkdir as mkdir7, readFile as readFile6, rename as rename5, writeFile as writeFile6 } from "fs/promises";
3743
+ import { mkdir as mkdir7, readFile as readFile7, rename as rename5, writeFile as writeFile6 } from "fs/promises";
3472
3744
  import { dirname as dirname8 } from "path";
3473
3745
  var FILE_VERSION3 = 1;
3474
3746
  async function read2() {
3475
3747
  try {
3476
- const text = await readFile6(paths.sessionsFile, "utf8");
3748
+ const text = await readFile7(paths.sessionsFile, "utf8");
3477
3749
  const parsed = JSON.parse(text);
3478
3750
  return Array.isArray(parsed.sessions) ? parsed.sessions : [];
3479
3751
  } catch (err) {
@@ -3528,6 +3800,156 @@ async function handleDmConsole(channel, cfg, msg) {
3528
3800
  });
3529
3801
  }
3530
3802
 
3803
+ // src/bot/media.ts
3804
+ import { mkdir as mkdir8, readdir as readdir2, rm as rm3, stat as stat3 } from "fs/promises";
3805
+ import { join as join10 } from "path";
3806
+ var MAX_IMAGES2 = 9;
3807
+ var MEDIA_TTL_MS = 60 * 6e4;
3808
+ var EXT_BY_CONTENT_TYPE = {
3809
+ "image/png": "png",
3810
+ "image/jpeg": "jpg",
3811
+ "image/jpg": "jpg",
3812
+ "image/gif": "gif",
3813
+ "image/webp": "webp",
3814
+ "image/bmp": "bmp",
3815
+ "image/heic": "heic",
3816
+ "image/heif": "heif",
3817
+ "image/tiff": "tiff"
3818
+ };
3819
+ function messageHasImages(msg) {
3820
+ if ((msg.resources ?? []).some((r) => r.type === "image")) return true;
3821
+ return msg.rawContentType === "merge_forward";
3822
+ }
3823
+ async function collectInboundImages(channel, msg) {
3824
+ let refs;
3825
+ try {
3826
+ refs = await gatherRefs(channel, msg);
3827
+ } catch (err) {
3828
+ log.warn("intake", "image-gather-failed", { err: String(err) });
3829
+ return [];
3830
+ }
3831
+ if (refs.length === 0) return [];
3832
+ await pruneOldMedia();
3833
+ try {
3834
+ await mkdir8(paths.mediaDir, { recursive: true });
3835
+ } catch {
3836
+ }
3837
+ const out = [];
3838
+ let index = 0;
3839
+ for (const ref of refs.slice(0, MAX_IMAGES2)) {
3840
+ const path = await downloadOne(channel, ref, index++);
3841
+ if (path) out.push(path);
3842
+ }
3843
+ log.info("intake", "images", { found: refs.length, downloaded: out.length });
3844
+ return out;
3845
+ }
3846
+ async function gatherRefs(channel, msg) {
3847
+ const refs = [];
3848
+ const seen = /* @__PURE__ */ new Set();
3849
+ const add = (messageId, fileKey) => {
3850
+ if (!fileKey || seen.has(fileKey)) return;
3851
+ seen.add(fileKey);
3852
+ refs.push({ messageId, fileKey });
3853
+ };
3854
+ for (const r of msg.resources ?? []) {
3855
+ if (r.type === "image") add(msg.messageId, r.fileKey);
3856
+ }
3857
+ if (msg.rawContentType === "merge_forward") {
3858
+ const items = await fetchSubMessages(channel, msg.messageId);
3859
+ for (const sub of items) {
3860
+ if (!sub.message_id || sub.message_id === msg.messageId) continue;
3861
+ for (const key of imageKeysFromContent(sub.msg_type, sub.body?.content)) {
3862
+ add(sub.message_id, key);
3863
+ }
3864
+ }
3865
+ }
3866
+ return refs;
3867
+ }
3868
+ async function fetchSubMessages(channel, messageId) {
3869
+ try {
3870
+ const res = await channel.rawClient.im.v1.message.get({ path: { message_id: messageId } });
3871
+ return res.data?.items ?? [];
3872
+ } catch (err) {
3873
+ log.warn("intake", "submessages-failed", { messageId, err: String(err) });
3874
+ return [];
3875
+ }
3876
+ }
3877
+ function imageKeysFromContent(msgType, content) {
3878
+ if (!content) return [];
3879
+ let parsed;
3880
+ try {
3881
+ parsed = JSON.parse(content);
3882
+ } catch {
3883
+ return [];
3884
+ }
3885
+ if (msgType === "image") {
3886
+ const key = parsed?.image_key;
3887
+ return key ? [key] : [];
3888
+ }
3889
+ const keys = [];
3890
+ walkForImageKeys(parsed, keys);
3891
+ return keys;
3892
+ }
3893
+ function walkForImageKeys(node, out) {
3894
+ if (!node || typeof node !== "object") return;
3895
+ if (Array.isArray(node)) {
3896
+ for (const child of node) walkForImageKeys(child, out);
3897
+ return;
3898
+ }
3899
+ const obj = node;
3900
+ if (obj.tag === "img" && typeof obj.image_key === "string") out.push(obj.image_key);
3901
+ for (const k of Object.keys(obj)) walkForImageKeys(obj[k], out);
3902
+ }
3903
+ async function downloadOne(channel, ref, index) {
3904
+ try {
3905
+ const res = await channel.rawClient.im.v1.messageResource.get({
3906
+ path: { message_id: ref.messageId, file_key: ref.fileKey },
3907
+ params: { type: "image" }
3908
+ });
3909
+ const ext = extFromHeaders(res.headers);
3910
+ const file = join10(paths.mediaDir, `${safeName(ref.fileKey)}-${index}.${ext}`);
3911
+ await res.writeFile(file);
3912
+ return file;
3913
+ } catch (err) {
3914
+ log.warn("intake", "image-download-failed", { fileKey: ref.fileKey.slice(0, 24), err: String(err) });
3915
+ return void 0;
3916
+ }
3917
+ }
3918
+ function extFromHeaders(headers) {
3919
+ const ct = readHeader(headers, "content-type");
3920
+ if (ct) {
3921
+ const base = ct.split(";")[0]?.trim().toLowerCase();
3922
+ if (base && EXT_BY_CONTENT_TYPE[base]) return EXT_BY_CONTENT_TYPE[base];
3923
+ }
3924
+ return "png";
3925
+ }
3926
+ function readHeader(headers, name) {
3927
+ if (!headers || typeof headers !== "object") return void 0;
3928
+ const h = headers;
3929
+ const raw = typeof h.get === "function" ? h.get(name) : h[name] ?? h[name.toLowerCase()];
3930
+ return typeof raw === "string" ? raw : Array.isArray(raw) ? String(raw[0]) : void 0;
3931
+ }
3932
+ function safeName(fileKey) {
3933
+ return fileKey.replace(/[^a-zA-Z0-9_-]/g, "").slice(-40) || "img";
3934
+ }
3935
+ async function pruneOldMedia() {
3936
+ let entries;
3937
+ try {
3938
+ entries = await readdir2(paths.mediaDir);
3939
+ } catch {
3940
+ return;
3941
+ }
3942
+ const cutoff = Date.now() - MEDIA_TTL_MS;
3943
+ for (const name of entries) {
3944
+ const file = join10(paths.mediaDir, name);
3945
+ try {
3946
+ const st = await stat3(file);
3947
+ if (st.mtimeMs < cutoff) await rm3(file, { force: true });
3948
+ } catch {
3949
+ }
3950
+ }
3951
+ }
3952
+
3531
3953
  // src/bot/comments.ts
3532
3954
  var SUPPORTED_FILE_TYPES = /* @__PURE__ */ new Set(["doc", "docx", "sheet", "file"]);
3533
3955
  var REPLY_MAX_CHARS = 2e3;
@@ -3922,19 +4344,34 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
3922
4344
  async function handleTurn(msg, text, sessionKey, flat, project) {
3923
4345
  const existing = active.get(sessionKey);
3924
4346
  if (existing) {
3925
- if (getPendingPolicy(cfg) === "steer" && existing.run && existing.thread) {
3926
- const tid = existing.run.turnId();
4347
+ const images = messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0;
4348
+ const cur = active.get(sessionKey);
4349
+ if (!cur) {
4350
+ startReservedRun(msg, text, sessionKey, flat, project, images);
4351
+ return;
4352
+ }
4353
+ if (getPendingPolicy(cfg) === "steer" && cur.run && cur.thread) {
4354
+ const tid = cur.run.turnId();
3927
4355
  if (tid) {
3928
4356
  try {
3929
- await existing.thread.steer({ text }, tid);
3930
- log.info("intake", "steer", { tid });
4357
+ await cur.thread.steer({ text, images }, tid);
4358
+ log.info("intake", "steer", { tid, images: images?.length ?? 0 });
3931
4359
  return;
3932
4360
  } catch (err) {
3933
4361
  log.warn("intake", "steer-failed", { err: String(err) });
3934
4362
  }
3935
4363
  }
3936
4364
  }
3937
- existing.queue.push(text);
4365
+ cur.queue.push({ text, images });
4366
+ log.info("intake", "queued", { depth: cur.queue.length });
4367
+ return;
4368
+ }
4369
+ startReservedRun(msg, text, sessionKey, flat, project);
4370
+ }
4371
+ function startReservedRun(msg, text, sessionKey, flat, project, preloadedImages) {
4372
+ const existing = active.get(sessionKey);
4373
+ if (existing) {
4374
+ existing.queue.push({ text, images: preloadedImages });
3938
4375
  log.info("intake", "queued", { depth: existing.queue.length });
3939
4376
  return;
3940
4377
  }
@@ -3943,6 +4380,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
3943
4380
  void withTrace({ chatId: msg.chatId, msgId: msg.messageId }, async () => {
3944
4381
  const reaction = runReaction(msg.messageId, !sema.hasFree());
3945
4382
  try {
4383
+ const images = preloadedImages ?? (messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0);
3946
4384
  let thread = await resolveThread(sessionKey, msg.chatId);
3947
4385
  if (!thread) {
3948
4386
  const cwd = project?.cwd ?? fallbackCwd;
@@ -3967,6 +4405,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
3967
4405
  flat,
3968
4406
  thread,
3969
4407
  firstText: text,
4408
+ images,
3970
4409
  knownThreadId: sessionKey,
3971
4410
  requesterOpenId: msg.senderId
3972
4411
  },
@@ -4019,7 +4458,8 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
4019
4458
  return;
4020
4459
  }
4021
4460
  const firstText = text || "\u4F60\u597D\uFF0C\u6211\u4EEC\u5F00\u59CB\u5427\u3002";
4022
- log.info("card", "start", { project: project?.name ?? "(unregistered)", model, effort });
4461
+ const images = messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0;
4462
+ log.info("card", "start", { project: project?.name ?? "(unregistered)", model, effort, images: images?.length ?? 0 });
4023
4463
  await launchRun(
4024
4464
  {
4025
4465
  chatId: msg.chatId,
@@ -4027,6 +4467,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
4027
4467
  replyInThread: true,
4028
4468
  thread,
4029
4469
  firstText,
4470
+ images,
4030
4471
  model,
4031
4472
  effort,
4032
4473
  cwd,
@@ -4432,14 +4873,14 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
4432
4873
  };
4433
4874
  let curCardKey;
4434
4875
  try {
4435
- let turnText = opts.firstText;
4876
+ let turnInput = { text: opts.firstText, images: opts.images };
4436
4877
  let replyTo = opts.replyTo;
4437
4878
  let replyInThread = opts.flat ? false : opts.replyInThread ?? Boolean(opts.knownThreadId);
4438
4879
  for (; ; ) {
4439
4880
  const rec = topicThreadId ? await getSession(topicThreadId) : void 0;
4440
4881
  const turnModel = rec?.model ?? opts.model;
4441
4882
  const turnEffort = rec?.effort ?? opts.effort;
4442
- const run = opts.thread.runStreamed({ text: turnText }, { model: turnModel, effort: turnEffort });
4883
+ const run = opts.thread.runStreamed(turnInput, { model: turnModel, effort: turnEffort });
4443
4884
  state.run = run;
4444
4885
  const render = new RunRender();
4445
4886
  render.showTools = getShowToolCalls(cfg);
@@ -4517,16 +4958,29 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
4517
4958
  const finalMsgId = cardMsgId;
4518
4959
  await adoptThreadId(finalMsgId);
4519
4960
  rc.cardKey = finalMsgId;
4961
+ const answerText = finalMessageText(rc.rs);
4962
+ const { fences } = extractCardFences(answerText);
4963
+ const imgSources = imageSources(answerText);
4964
+ if (imgSources.length > 0) {
4965
+ rc.images = await uploadOutboundImages(channel, imgSources, opts.cwd ?? fallbackCwd);
4966
+ }
4520
4967
  await stream2.updateCard(channel, buildRunCard(rc));
4521
4968
  runsByCard.delete(cardMsgId);
4522
4969
  promoteCard(finalMsgId, rc);
4970
+ for (const fence of fences) {
4971
+ try {
4972
+ await sendManagedCard(channel, opts.chatId, buildCleanCard(fence, rc.images), finalMsgId, !opts.flat);
4973
+ } catch (err) {
4974
+ log.fail("card", err, { phase: "clean-card" });
4975
+ }
4976
+ }
4523
4977
  if (topicThreadId) await patchSession(topicThreadId, { updatedAt: Date.now() });
4524
4978
  replyTo = finalMsgId;
4525
4979
  replyInThread = !opts.flat;
4526
4980
  log.info("card", "final", { terminal: render.terminal() });
4527
4981
  if (killed) break;
4528
4982
  if (state.queue.length === 0) break;
4529
- turnText = state.queue.shift();
4983
+ turnInput = state.queue.shift();
4530
4984
  }
4531
4985
  } catch (err) {
4532
4986
  log.fail("intake", err);
@@ -4890,7 +5344,7 @@ async function runUpdate(opts = {}) {
4890
5344
  }
4891
5345
 
4892
5346
  // src/cli/commands/bot.ts
4893
- import { rm as rm3 } from "fs/promises";
5347
+ import { rm as rm4 } from "fs/promises";
4894
5348
  async function runBotInit(name) {
4895
5349
  if (!ensureCodex()) {
4896
5350
  process.exitCode = 1;
@@ -4944,7 +5398,7 @@ async function runBotRm(name) {
4944
5398
  }
4945
5399
  const after = await removeBot(bot2.appId);
4946
5400
  await removeSecret(secretKeyForApp(bot2.appId));
4947
- await rm3(botDir(bot2.appId), { recursive: true, force: true });
5401
+ await rm4(botDir(bot2.appId), { recursive: true, force: true });
4948
5402
  console.log(`\u2713 \u5DF2\u79FB\u9664\u673A\u5668\u4EBA\u300C${bot2.name}\u300D(${bot2.appId})\uFF1A\u6CE8\u518C\u8868 + \u5BC6\u94A5 + \u72B6\u6001\u76EE\u5F55(projects/sessions)\u3002`);
4949
5403
  if (after.bots.length === 0) {
4950
5404
  console.log(" \u5DF2\u65E0\u4EFB\u4F55\u673A\u5668\u4EBA\uFF0C`bot init` \u91CD\u65B0\u521B\u5EFA\u3002");
@@ -5002,15 +5456,15 @@ async function secretsRemove(id) {
5002
5456
  console.log(ok ? `\u2713 \u5DF2\u5220\u9664: ${id}` : `\u672A\u627E\u5230: ${id}`);
5003
5457
  }
5004
5458
  function readStdin() {
5005
- return new Promise((resolve5) => {
5459
+ return new Promise((resolve6) => {
5006
5460
  let data = "";
5007
5461
  if (process.stdin.isTTY) {
5008
- resolve5("");
5462
+ resolve6("");
5009
5463
  return;
5010
5464
  }
5011
5465
  process.stdin.setEncoding("utf8");
5012
5466
  process.stdin.on("data", (c) => data += c);
5013
- process.stdin.on("end", () => resolve5(data));
5467
+ process.stdin.on("end", () => resolve6(data));
5014
5468
  });
5015
5469
  }
5016
5470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelzen/feishu-codex-bridge",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Bridge Feishu/Lark messenger with local Codex via app-server (project=group, thread=session)",
5
5
  "type": "module",
6
6
  "bin": {