@buzzni/saycode-cli 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,10 +211,20 @@ saycode를 따로 설치하지 않은 작업PC는 `npx @buzzni/saycode-cli searc
211
211
  마무리합니다(specs/codexcli-approval-session-binding R12). 값이 없거나 모르는 값이면 아무것도 싣지 않고,
212
212
  자식은 지금처럼 사람의 답을 기다립니다.
213
213
 
214
+ `agent prompt`의 결과는 `resultVersion:2`입니다. `ok:true`는 서버 저장 행을 다시 조회·복호화하여 본문과 설정까지 확인했다는 뜻이며 `storage.seq`가 확인한 위치입니다. `delivered:true`는 그 서버 메시지 ID의 **런타임 소비 증거**가 있어야 나옵니다. 구버전의 `delivered`는 같은 뜻으로 읽으면 안 됩니다. 큐 수락(receipt)만으로는 소비나 답변 성공이 아닙니다.
215
+
216
+ `--wait` 없이도 저장 뒤 runtime의 수락·거부 receipt를 최대 5초 기다려 거부를 바로 알려 주며, receipt가 없으면 `outcome:unconfirmed`로 돌아옵니다. `--wait`는 저장 이후 같은 입력 ID의 증인으로 기다립니다(최대 600초, 저장·검사 포함 620초). `response.attribution=bound`일 때만 `response.answer`가 이번 질문의 단독 답변이며, 공동 배치·steer·질문/자유문 권한 답변이 끼면 `consumed-with`와 입력 목록/사유를 돌려줍니다. receipt timeout은 `outcome:unconfirmed`이고 실패로 확정하지 않습니다. 소비는 확인됐지만 마감까지 작업이 끝나지 않으면 같은 증거를 담은 `wait_timeout`(exit 3)이고, 소비된 턴이 `--until` 조건 없이 끝나면(예: `consumed-with`) 마감을 기다리지 않고 `outcome:unconfirmed`로 바로 돌려줍니다. `next.command` 또는 `happy agent read <id> --since <storage.seq>`로 늦은 receipt·소비를 정산하세요.
217
+
218
+ 재실행에 대비하려면 **송신 전** `--local-id <id>`와 대상·본문·설정을 보관합니다(1–128 ASCII 영숫자 및 `._:-`). 같은 ID/동일 intent로 저장 응답 유실을 한 번만 재시도하며, 다른 본문이나 설정이면 `localid_conflict`입니다. CLI가 죽은 뒤에는 보관한 같은 ID로 다시 실행해 원래 저장 행을 확인합니다. 그사이 대상 runtime이 재시작되면 확인 조건이 달라져 `localid_conflict`가 되지만, 본문이 같으면(`storage.existing.sameBody:true`) `storage.existing`과 `next.command`(`happy agent read <id> --since <seq>`)로 원래 요청의 소비 여부를 확인할 수 있습니다. 본문이 다르면 그 행은 다른 질문이므로 `next.command`를 주지 않습니다. ID를 보관하지 않은 송신은 본문·시각으로 찾지 않으므로 결과를 알 수 없습니다. 복호화/조회 실패는 `storage-verification-unknown`입니다. 미확인 요청을 새 ID로 자동 재전송하지 않습니다.
219
+
220
+ 입력 증거는 지원 Claude/Codex 세션의 명시적 opt-in(`HAPPY_INPUT_EVIDENCE=1`, 기본 OFF)과 살아 있는 동일 runtime/remote epoch 확인이 필요합니다. 그 확인이 안 되는 세션(ON marker 없는 .358, 미지원·OFF 세션)은 `CLI_GUARDED_PROMPT_ROLLOUT`(기본 off) 동안 이전처럼 보내되 결과는 `resultVersion:2`, `delivered:false`, `delivery.state:"unsupported"`로 소비를 주장하지 않습니다. 이 플래그를 켜면 같은 세션은 `prompt_unsupported`로 보내기 전에 닫힙니다. Desktop 자동 opt-in은 T2r pin 전까지 꺼져 있습니다. A6 생성 안내는 T4rt와 함께 수락합니다. 허브 자동 알림은 `HUB_BRIDGE_OWNERSHIP_ROLLOUT`(기본 off) 동안 이전과 같고, 켜면 main이 개인은 같은 계정, 회사는 confirmed owner만 수신하도록 확인하며 확인 실패 시 배지에서 복구를 안내합니다. 사람의 직접 입력은 유지됩니다.
221
+
214
222
  `steer` 성공의 `accepted:true`는 세션 RPC가 요청을 받았다는 뜻이며 실제 transcript 전달 완료를
215
223
  뜻하지 않습니다(`delivered:false`). 끝난 턴의 후속 작업에는 `prompt`를 사용합니다. 에이전트 세션은
216
224
  자기 자신에게 입력할 수 없고, `steer`와 `stop`은 depth와 관계없이 원장에 기록된 자기 후손에게만 보낼 수
217
- 있습니다. 후손이 아닌 세션에 `prompt`하려면 소유가 검증돼야 하며, `not_owned`를 받으면 `read`로만 보고
225
+ 있습니다. 예외로 사람이 시작한 Claude·Codex 허브(broker 경로, depth 0)는 소유가 검증된 형제 세션(`ls`의
226
+ `ownership.relation:"own-sibling"`)에도 보낼 수 있으며, 이때 `stop`은 프로세스를 끄지 않고 지금 응답만 끊습니다
227
+ (`mode:"turn-abort"`, 터미널에서 쓰는 Claude는 stop을 `controlled-by-user`, steer를 `local-mode`로 거부). 형제 결과에는 대상 `title`과 `ownership.relation`이 붙습니다. 형제에게 보내기 전에 대상 제목과 전체 ID를 확인합니다. 후손이 아닌 세션에 `prompt`하려면 소유가 검증돼야 하며, `not_owned`를 받으면 `read`로만 보고
218
228
  다시 시도하지 않습니다(ADR-078). `stop`은 보관·삭제를 하지 않습니다. spawn 한도(`whoami`의 `maxSpawn`)는 세션이
219
229
  도는 머신의 코어·메모리에서 산정하므로 머신마다 다르고, 최근 6시간 안의 spawn만 세는 롤링 윈도우라
220
230
  소진돼도 시간이 지나면 회복됩니다(`budgetResetsAt`, ADR-063). 데몬이 확인하지 못한 시도도 같은
@@ -510,3 +520,9 @@ node ../../scripts/verify-cli-tarball.mjs out/cli/buzzni-saycode-cli-*.tgz
510
520
 
511
521
  번들에 포함된 서드파티 오픈소스는 각자의 라이선스를 유지하며, 목록과 전문은 패키지에
512
522
  동봉된 `THIRD-PARTY-NOTICES.md`에 있습니다 (빌드 시 실제 번들 내용에서 자동 생성).
523
+
524
+ T2 응답은 소비한 native turn과 runtime에 결속합니다. `prompt --wait`의 질문·구조화 출력도 그 턴만 판정하며 `structuredOutput`과 `structured_output_missing`을 v2에 유지합니다. 명시적 runtime 거부는 `prompt_rejected`, exit 3이며 localId·storage·거부 증거를 보존합니다. 백그라운드 후속 관측은 `response.followUps`에 따로 남기고 최초 답변을 덮지 않습니다.
525
+
526
+ 송신 당시 검증된 runtime/epoch는 암호화 요청에 보존하므로 host 재시작 뒤에도 `read --since`로 과거 소비 사실을 조회할 수 있습니다. 이 기록은 새 runtime에 송신 권한을 주지 않습니다. 검증 기록이 없는 구 요청은 현재 runtime이 같고 ON이어도 unknown으로 남습니다. 기록을 소급 생성하거나 과거 소비를 확인됐다고 표시하지 않습니다.
527
+
528
+ Codex 명령/패치 승인은 tool 시작 전에 대기할 수 있지만 .359의 REST pending 상태에는 approval↔turn 결속이 없습니다. `prompt --wait --until waiting-input`은 이 승인을 현재 턴으로 추측하지 않습니다. 해당 대기가 확인되지 않은 채 timeout되면 `outcome:unconfirmed`, `waitingInput:{state:"unsupported",reason:"pending-approval-turn-binding-unavailable"}`를 반환합니다. 이는 pending 승인이 요청 B에 속한다는 뜻이 아닙니다. Claude 질문·Codex 질문은 실제 tool-call ID/턴 결속이 있을 때 판정하며, 채널 routing requestId로 대체하지 않습니다. 미확인 결과를 새 메시지로 자동 재시도하지 마세요.
@@ -7,7 +7,7 @@ const ASSET_PERMISSIONS = ["assets.read", "assets.write", "files.select", "draft
7
7
  function usesAssetWorkflow(permissions) {
8
8
  return permissions.some((permission) => ASSET_PERMISSIONS.includes(permission));
9
9
  }
10
- const LONG_RUNNING_PERMISSIONS = ["localTools.inspect", "localTools.install", "localTools.control", "localBrowser.setup", "machine.run"];
10
+ const LONG_RUNNING_PERMISSIONS = ["localTools.inspect", "localTools.install", "localTools.control", "localBrowser.setup", "browser.runtimeSetup", "machine.run"];
11
11
  function extensionRequestTimeoutMs(permissions, side) {
12
12
  const long = usesAssetWorkflow(permissions) || permissions.some((permission) => LONG_RUNNING_PERMISSIONS.includes(permission));
13
13
  if (!long) return void 0;
@@ -469,8 +469,11 @@ function parseExtensionArtifactActions(value, path2 = "manifest.contributes.arti
469
469
  };
470
470
  });
471
471
  }
472
- const CHANNEL_API_VERSION = 1;
472
+ const CHANNEL_API_VERSION = 2;
473
473
  const CHANNEL_MINIMUM_API_VERSION = 1;
474
+ function isSupportedChannelApiVersion(value) {
475
+ return typeof value === "number" && Number.isInteger(value) && value >= CHANNEL_MINIMUM_API_VERSION && value <= CHANNEL_API_VERSION;
476
+ }
474
477
  const CHANNEL_PROVIDERS = ["telegram", "slack", "discord"];
475
478
  const CHANNEL_COMMAND_SCHEMA = Object.freeze({
476
479
  pair: Object.freeze(["code!"]),
@@ -611,6 +614,7 @@ const EXTENSION_PERMISSIONS = [
611
614
  "localTools.install",
612
615
  "localTools.control",
613
616
  "localBrowser.setup",
617
+ "browser.runtimeSetup",
614
618
  "network.fetch",
615
619
  "notifications.show",
616
620
  "storage.read",
@@ -633,7 +637,11 @@ const EXTENSION_PERMISSIONS = [
633
637
  "channels.transport",
634
638
  "sessions.read",
635
639
  "sessions.control",
636
- "sessions.approveOnce"
640
+ "sessions.approveOnce",
641
+ // Core → adapter, not a capability the adapter invokes: Core sends the current input and the
642
+ // thread's earlier messages (other people's words included) for display-text normalization
643
+ // (specs/slack-bot-improvements contracts §2). Channel API 2 only.
644
+ "channels.normalize"
637
645
  ];
638
646
  const CONVERSATION_MODS_INTERNAL = typeof __SAYCODE_MODS_INTERNAL__ !== "undefined" && __SAYCODE_MODS_INTERNAL__ === true;
639
647
  const SAYCODE_EXTENSION_API_VERSION = CONVERSATION_MODS_INTERNAL ? 5 : 4;
@@ -644,7 +652,7 @@ const SAYCODE_CHANNEL_API_VERSION = CHANNEL_API_VERSION;
644
652
  const SAYCODE_CHANNEL_MINIMUM_API_VERSION = CHANNEL_MINIMUM_API_VERSION;
645
653
  class UnsupportedChannelApiVersionError extends Error {
646
654
  constructor(channelApiVersion) {
647
- super(`manifest.channelApiVersion: channel API version ${String(channelApiVersion)} is unsupported; Desktop supports ${String(SAYCODE_CHANNEL_API_VERSION)}`);
655
+ super(`manifest.channelApiVersion: channel API version ${String(channelApiVersion)} is unsupported; Desktop supports ${`${SAYCODE_CHANNEL_MINIMUM_API_VERSION}-${SAYCODE_CHANNEL_API_VERSION}`}`);
648
656
  this.channelApiVersion = channelApiVersion;
649
657
  }
650
658
  name = "UnsupportedChannelApiVersionError";
@@ -667,7 +675,7 @@ const V2_PERMISSIONS = /* @__PURE__ */ new Set([
667
675
  "browserViewer.install",
668
676
  "browserViewer.installChrome"
669
677
  ]);
670
- const V3_PERMISSIONS = /* @__PURE__ */ new Set(["machine.run", "artifacts.publishPublic", "assets.read", "assets.write", "files.select", "drafts.prepare", "localTools.inspect", "localTools.install", "localTools.control", "localBrowser.setup"]);
678
+ const V3_PERMISSIONS = /* @__PURE__ */ new Set(["machine.run", "artifacts.publishPublic", "assets.read", "assets.write", "files.select", "drafts.prepare", "localTools.inspect", "localTools.install", "localTools.control", "localBrowser.setup", "browser.runtimeSetup"]);
671
679
  const CHANNEL_PERMISSIONS = /* @__PURE__ */ new Set([
672
680
  "channels.receive",
673
681
  "channels.ack",
@@ -675,7 +683,8 @@ const CHANNEL_PERMISSIONS = /* @__PURE__ */ new Set([
675
683
  "channels.transport",
676
684
  "sessions.read",
677
685
  "sessions.control",
678
- "sessions.approveOnce"
686
+ "sessions.approveOnce",
687
+ "channels.normalize"
679
688
  ]);
680
689
  const TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
681
690
  "id",
@@ -925,6 +934,9 @@ function parseExtensionManifest(value, options) {
925
934
  if (channelApiVersion === void 0) {
926
935
  fail$5(`manifest.permissions[${index}]`, "requires manifest.channelApiVersion");
927
936
  }
937
+ if (typed === "channels.normalize" && channelApiVersion < 2) {
938
+ fail$5(`manifest.permissions[${index}]`, "channels.normalize requires channel API version 2");
939
+ }
928
940
  }
929
941
  return typed;
930
942
  });
@@ -1073,6 +1085,246 @@ function parseChannelApprovalRequest(value) {
1073
1085
  }
1074
1086
  return { approvalHandle, approveLabel, denyLabel };
1075
1087
  }
1088
+ const NORMALIZE_ADDITION_SOURCES = ["blocks", "quote", "forward", "table", "unfurl", "files"];
1089
+ const MAX_NORMALIZE_ITEMS = 200;
1090
+ const MAX_USER_LABELS = 200;
1091
+ const MAX_BLOCKS = 50;
1092
+ const MAX_ATTACHMENTS = 20;
1093
+ const MAX_FILES = 20;
1094
+ const MAX_RICH_TEXT_DEPTH = 6;
1095
+ const MAX_URL_LENGTH = 2048;
1096
+ const MAX_ADDITIONS_BYTES = 8192;
1097
+ const MAX_PRIMARY_BYTES = 65536;
1098
+ const ITEM_REF = /^i(0|[1-9]\d?|1\d\d)$/;
1099
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1100
+ const isString = (value) => typeof value === "string";
1101
+ function projectSlackMessage(raw) {
1102
+ const state = { truncated: false };
1103
+ const message = isRecord(raw) ? raw : {};
1104
+ const projection = {};
1105
+ const blocks = projectBlocks(message.blocks, state);
1106
+ if (blocks) projection.blocks = blocks;
1107
+ const attachments = capped(Array.isArray(message.attachments) ? message.attachments.filter(isRecord) : [], MAX_ATTACHMENTS, state).map((attachment) => projectAttachment(attachment, state));
1108
+ if (attachments.length > 0) projection.attachments = attachments;
1109
+ const files = capped(Array.isArray(message.files) ? message.files.filter(isRecord) : [], MAX_FILES, state).map(projectFile);
1110
+ if (files.length > 0) projection.files = files;
1111
+ if (isString(message.subtype)) projection.subtype = message.subtype;
1112
+ return { projection, truncated: state.truncated };
1113
+ }
1114
+ function capped(items, max, state) {
1115
+ if (items.length > max) state.truncated = true;
1116
+ return items.slice(0, max);
1117
+ }
1118
+ function projectBlocks(value, state) {
1119
+ if (!Array.isArray(value)) return void 0;
1120
+ const projected = value.map((block) => projectBlock(block, state)).filter((block) => block !== null);
1121
+ return projected.length > 0 ? capped(projected, MAX_BLOCKS, state) : void 0;
1122
+ }
1123
+ function projectBlock(block, state) {
1124
+ if (!isRecord(block)) return null;
1125
+ switch (block.type) {
1126
+ case "rich_text":
1127
+ return { type: "rich_text", elements: projectContainers(block.elements, 1, state) };
1128
+ case "section": {
1129
+ const section = { type: "section" };
1130
+ const text2 = projectTextObject(block.text);
1131
+ if (text2) section.text = text2;
1132
+ if (Array.isArray(block.fields)) section.fields = block.fields.map(projectTextObject).filter((field) => field !== null);
1133
+ return section;
1134
+ }
1135
+ case "header": {
1136
+ const text2 = projectTextObject(block.text);
1137
+ return text2 ? { type: "header", text: text2 } : null;
1138
+ }
1139
+ case "context":
1140
+ return { type: "context", elements: Array.isArray(block.elements) ? block.elements.map(projectTextObject).filter((element) => element !== null) : [] };
1141
+ case "table":
1142
+ return { type: "table", rows: Array.isArray(block.rows) ? block.rows.filter(Array.isArray).map((row2) => row2.flatMap((cell) => {
1143
+ if (isRecord(cell) && cell.type === "raw_text" && isString(cell.text)) return [{ type: "raw_text", text: cell.text }];
1144
+ if (isRecord(cell) && cell.type === "rich_text") return [{ type: "rich_text", elements: projectContainers(cell.elements, 1, state) }];
1145
+ return [];
1146
+ })) : [] };
1147
+ default:
1148
+ return null;
1149
+ }
1150
+ }
1151
+ function projectTextObject(value) {
1152
+ if (!isRecord(value) || value.type !== "mrkdwn" && value.type !== "plain_text" || !isString(value.text)) return null;
1153
+ return { type: value.type, text: value.text };
1154
+ }
1155
+ const CONTAINER_TYPES = /* @__PURE__ */ new Set(["rich_text_section", "rich_text_quote", "rich_text_preformatted", "rich_text_list"]);
1156
+ function projectContainers(value, depth, state) {
1157
+ if (!Array.isArray(value)) return [];
1158
+ return value.flatMap((node) => {
1159
+ const container = isRecord(node) && CONTAINER_TYPES.has(String(node.type)) ? projectContainer(node, depth, state) : null;
1160
+ return container ? [container] : [];
1161
+ });
1162
+ }
1163
+ function projectContainer(node, depth, state) {
1164
+ if (depth > MAX_RICH_TEXT_DEPTH) {
1165
+ state.truncated = true;
1166
+ return null;
1167
+ }
1168
+ const elements = (Array.isArray(node.elements) ? node.elements : []).flatMap((child) => {
1169
+ if (isRecord(child) && CONTAINER_TYPES.has(String(child.type))) {
1170
+ const nested = projectContainer(child, depth + 1, state);
1171
+ return nested ? [nested] : [];
1172
+ }
1173
+ const leaf = projectLeaf(child);
1174
+ return leaf ? [leaf] : [];
1175
+ });
1176
+ const container = { type: node.type, elements };
1177
+ if (node.style === "bullet" || node.style === "ordered") container.style = node.style;
1178
+ return container;
1179
+ }
1180
+ function projectLeaf(node) {
1181
+ if (!isRecord(node)) return null;
1182
+ switch (node.type) {
1183
+ case "text": {
1184
+ if (!isString(node.text)) return null;
1185
+ const leaf = { type: "text", text: node.text };
1186
+ if (isRecord(node.style)) {
1187
+ const style = Object.fromEntries(["bold", "italic", "strike", "code"].filter((key) => typeof node.style[key] === "boolean").map((key) => [key, node.style[key]]));
1188
+ if (Object.keys(style).length > 0) leaf.style = style;
1189
+ }
1190
+ return leaf;
1191
+ }
1192
+ case "link": {
1193
+ const label = isString(node.text) ? node.text : void 0;
1194
+ if (!httpUrl(node.url)) return label === void 0 ? null : { type: "text", text: label };
1195
+ return label === void 0 ? { type: "link", url: node.url } : { type: "link", url: node.url, text: label };
1196
+ }
1197
+ case "user":
1198
+ return isString(node.user_id) ? { type: "user", user_id: node.user_id } : null;
1199
+ case "channel":
1200
+ return isString(node.channel_id) ? { type: "channel", channel_id: node.channel_id } : null;
1201
+ case "usergroup":
1202
+ return isString(node.usergroup_id) ? { type: "usergroup", usergroup_id: node.usergroup_id } : null;
1203
+ case "emoji":
1204
+ return isString(node.name) ? { type: "emoji", name: node.name } : null;
1205
+ case "broadcast":
1206
+ return node.range === "here" || node.range === "channel" || node.range === "everyone" ? { type: "broadcast", range: node.range } : null;
1207
+ default:
1208
+ return null;
1209
+ }
1210
+ }
1211
+ const ATTACHMENT_TEXT_KEYS = ["text", "fallback", "pretext", "title", "author_name", "footer"];
1212
+ const ATTACHMENT_URL_KEYS = ["title_link", "from_url", "original_url"];
1213
+ function projectAttachment(attachment, state) {
1214
+ const projected = {};
1215
+ for (const key of ATTACHMENT_TEXT_KEYS) if (isString(attachment[key])) projected[key] = attachment[key];
1216
+ for (const key of ATTACHMENT_URL_KEYS) if (httpUrl(attachment[key])) projected[key] = attachment[key];
1217
+ if (typeof attachment.is_share === "boolean") projected.is_share = attachment.is_share;
1218
+ if (typeof attachment.is_msg_unfurl === "boolean") projected.is_msg_unfurl = attachment.is_msg_unfurl;
1219
+ if (Array.isArray(attachment.message_blocks)) {
1220
+ projected.message_blocks = attachment.message_blocks.filter(isRecord).map((forwarded) => ({
1221
+ message: { blocks: projectBlocks(isRecord(forwarded.message) ? forwarded.message.blocks : void 0, state) ?? [] }
1222
+ }));
1223
+ }
1224
+ return projected;
1225
+ }
1226
+ function projectFile(file) {
1227
+ const projected = {};
1228
+ for (const key of ["name", "title", "filetype", "mimetype"]) if (isString(file[key])) projected[key] = file[key];
1229
+ if (Number.isSafeInteger(file.size) && file.size >= 0) projected.size = file.size;
1230
+ return projected;
1231
+ }
1232
+ function httpUrl(value) {
1233
+ return isString(value) && value.length <= MAX_URL_LENGTH && /^https?:\/\//i.test(value);
1234
+ }
1235
+ function parseChannelNormalizeRequest(value) {
1236
+ const request = plainRecord(value, "normalize request");
1237
+ exactKeySet(request, ["apiVersion", "channelId", "connectionId", "provider", "connectionRevision", "requestId", "purpose", "userLabels", "items"], "normalize request");
1238
+ if (request.apiVersion !== 2 || request.provider !== "slack") fail("normalize request is channel API 2 Slack only");
1239
+ if (request.purpose !== "currentInput" && request.purpose !== "threadHistory") fail("invalid normalize purpose");
1240
+ if (!Number.isSafeInteger(request.connectionRevision) || request.connectionRevision < 1) fail("invalid normalize connection revision");
1241
+ const labels = plainRecord(request.userLabels, "normalize userLabels");
1242
+ if (Object.keys(labels).length > MAX_USER_LABELS || Object.entries(labels).some(([key, label]) => key.length === 0 || key.length > 64 || !isString(label) || label.length > 256)) {
1243
+ fail("invalid normalize userLabels");
1244
+ }
1245
+ if (!Array.isArray(request.items) || request.items.length < 1 || request.items.length > MAX_NORMALIZE_ITEMS) fail("invalid normalize items");
1246
+ const refs = /* @__PURE__ */ new Set();
1247
+ const items = request.items.map((value2) => {
1248
+ const item = plainRecord(value2, "normalize item");
1249
+ exactKeySet(item, ["itemRef", "primaryText", "projection", "maxAdditionsBytes"], "normalize item");
1250
+ if (!isString(item.itemRef) || !ITEM_REF.test(item.itemRef) || refs.has(item.itemRef)) fail("invalid normalize itemRef");
1251
+ refs.add(item.itemRef);
1252
+ if (!isString(item.primaryText) || utf8Bytes(item.primaryText) > MAX_PRIMARY_BYTES) fail("invalid normalize primaryText");
1253
+ if (!Number.isSafeInteger(item.maxAdditionsBytes) || item.maxAdditionsBytes < 1 || item.maxAdditionsBytes > MAX_ADDITIONS_BYTES) fail("invalid normalize maxAdditionsBytes");
1254
+ const projected = projectSlackMessage(item.projection);
1255
+ if (!isRecord(item.projection) || projected.truncated || !sameJson(projected.projection, item.projection)) fail("normalize projection is not a Core projection");
1256
+ return { itemRef: item.itemRef, primaryText: item.primaryText, projection: projected.projection, maxAdditionsBytes: item.maxAdditionsBytes };
1257
+ });
1258
+ return {
1259
+ apiVersion: 2,
1260
+ provider: "slack",
1261
+ purpose: request.purpose,
1262
+ channelId: boundedText(request.channelId, "channelId"),
1263
+ connectionId: boundedText(request.connectionId, "connectionId"),
1264
+ connectionRevision: request.connectionRevision,
1265
+ requestId: boundedText(request.requestId, "requestId"),
1266
+ userLabels: { ...labels },
1267
+ items
1268
+ };
1269
+ }
1270
+ function parseChannelNormalizeResult(request, value) {
1271
+ const result = plainRecord(value, "normalize result");
1272
+ exactKeySet(result, ["items"], "normalize result");
1273
+ if (!Array.isArray(result.items)) fail("invalid normalize result items");
1274
+ const wanted = new Map(request.items.map((item) => [item.itemRef, item]));
1275
+ const parsed = /* @__PURE__ */ new Map();
1276
+ for (const value2 of result.items) {
1277
+ const item = plainRecord(value2, "normalize result item");
1278
+ exactKeySet(item, ["itemRef", "additions", "truncated"], "normalize result item");
1279
+ const requested = isString(item.itemRef) ? wanted.get(item.itemRef) : void 0;
1280
+ if (!requested || parsed.has(requested.itemRef)) fail("normalize result items differ from the request");
1281
+ if (typeof item.truncated !== "boolean" || !Array.isArray(item.additions)) fail("invalid normalize result item");
1282
+ let bytes = 0;
1283
+ const additions = item.additions.map((value3) => {
1284
+ const addition = plainRecord(value3, "normalize addition");
1285
+ exactKeySet(addition, ["source", "text"], "normalize addition");
1286
+ if (!NORMALIZE_ADDITION_SOURCES.includes(addition.source) || !isString(addition.text) || addition.text.includes("\0") || hasLoneSurrogate(addition.text)) fail("invalid normalize addition");
1287
+ bytes += utf8Bytes(addition.text);
1288
+ return { source: addition.source, text: addition.text };
1289
+ });
1290
+ if (bytes > requested.maxAdditionsBytes) fail("normalize additions exceed their budget");
1291
+ parsed.set(requested.itemRef, { itemRef: requested.itemRef, additions: withoutRepeatedLines(requested.primaryText, additions), truncated: item.truncated });
1292
+ }
1293
+ if (parsed.size !== wanted.size) fail("normalize result items differ from the request");
1294
+ return request.items.map((item) => parsed.get(item.itemRef));
1295
+ }
1296
+ function withoutRepeatedLines(primaryText, additions) {
1297
+ const fold = (line) => line.replace(/\s+/gu, " ").trim();
1298
+ const known = new Set(primaryText.split("\n").map(fold).filter(Boolean));
1299
+ const kept = [];
1300
+ for (const addition of additions) {
1301
+ const lines2 = addition.text.split("\n");
1302
+ const remaining = lines2.filter((line) => fold(line) === "" || !known.has(fold(line)));
1303
+ for (const line of lines2) if (fold(line)) known.add(fold(line));
1304
+ if (remaining.some((line) => fold(line) !== "")) kept.push({ source: addition.source, text: remaining.join("\n") });
1305
+ }
1306
+ return kept;
1307
+ }
1308
+ function exactKeySet(record2, keys2, path2) {
1309
+ const actual = Object.keys(record2);
1310
+ if (actual.length !== keys2.length || !keys2.every((key) => actual.includes(key))) fail(`${path2} has unexpected keys`);
1311
+ }
1312
+ function boundedText(value, path2) {
1313
+ if (!isString(value) || value.length === 0 || value.length > 200) fail(`invalid normalize ${path2}`);
1314
+ return value;
1315
+ }
1316
+ function sameJson(a, b) {
1317
+ if (Array.isArray(a) || Array.isArray(b)) {
1318
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => sameJson(item, b[index]));
1319
+ }
1320
+ if (isRecord(a) && isRecord(b)) {
1321
+ const keys2 = Object.keys(a);
1322
+ return keys2.length === Object.keys(b).length && keys2.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key]));
1323
+ }
1324
+ return Object.is(a, b);
1325
+ }
1326
+ const utf8Bytes = (text2) => new TextEncoder().encode(text2).length;
1327
+ const hasLoneSurrogate = (text2) => /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text2);
1076
1328
  function finiteNumber(value, path2) {
1077
1329
  if (typeof value !== "number" || !Number.isFinite(value)) fail(`${path2} must be a finite number`);
1078
1330
  return value;
@@ -1162,13 +1414,15 @@ function parseRequest(message) {
1162
1414
  "maxChunkUtf16Units",
1163
1415
  "maxChunks"
1164
1416
  ];
1417
+ const v2 = params.apiVersion === 2;
1165
1418
  exactKeys(
1166
1419
  params,
1167
- "approval" in params ? [...formatKeys, "approval"] : formatKeys,
1420
+ v2 ? [...formatKeys, "mode", "dialect"] : "approval" in params ? [...formatKeys, "approval"] : formatKeys,
1168
1421
  "request.params"
1169
1422
  );
1423
+ if (v2 && (params.mode !== "verbatim" && params.mode !== "rendered" || params.dialect !== "slack-mrkdwn" || params.kind === "approval")) fail("invalid channel API 2 formatter request");
1170
1424
  const lifecycle = parseRequest({ kind: "request", id: id2, method: "channel.lifecycle", params: {
1171
- apiVersion: params.apiVersion,
1425
+ apiVersion: v2 ? 1 : params.apiVersion,
1172
1426
  channelId: params.channelId,
1173
1427
  connectionId: params.connectionId,
1174
1428
  provider: params.provider,
@@ -1184,16 +1438,26 @@ function parseRequest(message) {
1184
1438
  }
1185
1439
  const approval = params.approval === void 0 ? void 0 : parseChannelApprovalRequest(params.approval);
1186
1440
  const { state: _state, ...identity } = lifecycle.params;
1441
+ const bounds = { requestId, text: body, maxChunks: Number(params.maxChunks), maxChunkUtf16Units: Number(params.maxChunkUtf16Units) };
1442
+ if (v2) {
1443
+ return { kind: "request", id: id2, method, params: {
1444
+ ...identity,
1445
+ ...bounds,
1446
+ apiVersion: 2,
1447
+ kind: params.kind,
1448
+ mode: params.mode,
1449
+ dialect: "slack-mrkdwn"
1450
+ } };
1451
+ }
1187
1452
  return { kind: "request", id: id2, method, params: {
1188
1453
  ...identity,
1189
- requestId,
1190
- text: body,
1454
+ ...bounds,
1191
1455
  kind: params.kind,
1192
- maxChunks: Number(params.maxChunks),
1193
- maxChunkUtf16Units: Number(params.maxChunkUtf16Units),
1194
1456
  ...approval ? { approval } : {}
1195
1457
  } };
1196
1458
  }
1459
+ case "channel.normalizeMessages":
1460
+ return { kind: "request", id: id2, method, params: parseChannelNormalizeRequest(params) };
1197
1461
  case "invokeCommand": {
1198
1462
  exactKeys(params, ["commandId", "args"], "request.params");
1199
1463
  if (!Array.isArray(params.args)) fail("request.params.args must be an array");
@@ -1276,6 +1540,17 @@ function parseExtensionWireMessage(value) {
1276
1540
  if (message.kind === "event") return parseEvent(message);
1277
1541
  fail("message.kind is unknown");
1278
1542
  }
1543
+ function parseChannelRenderedReplyResult(value) {
1544
+ const result = plainRecord(value, "channel rendered result");
1545
+ if (Object.keys(result).join() !== "rendered" || !Array.isArray(result.rendered)) fail("invalid channel rendered result");
1546
+ return {
1547
+ rendered: result.rendered.map((value2) => {
1548
+ const chunk = plainRecord(value2, "channel rendered chunk");
1549
+ if (Object.keys(chunk).sort().join() !== "sourceEnd,sourceStart,text" || typeof chunk.text !== "string" || !Number.isSafeInteger(chunk.sourceStart) || !Number.isSafeInteger(chunk.sourceEnd)) fail("invalid channel rendered chunk");
1550
+ return { text: chunk.text, sourceStart: chunk.sourceStart, sourceEnd: chunk.sourceEnd };
1551
+ })
1552
+ };
1553
+ }
1279
1554
  const TIMEOUT = Symbol("extension-request-timeout");
1280
1555
  function requestEnvelope(value) {
1281
1556
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
@@ -1297,6 +1572,7 @@ class ExtensionHostRuntime {
1297
1572
  widgets = /* @__PURE__ */ new Map();
1298
1573
  channelHandlers = /* @__PURE__ */ new Map();
1299
1574
  channelFormatters = /* @__PURE__ */ new Map();
1575
+ channelNormalizers = /* @__PURE__ */ new Map();
1300
1576
  channelIntakeHandlers = /* @__PURE__ */ new Map();
1301
1577
  channelStates = /* @__PURE__ */ new Map();
1302
1578
  channelCalls = /* @__PURE__ */ new Map();
@@ -1432,6 +1708,7 @@ class ExtensionHostRuntime {
1432
1708
  this.commandHandlers.clear();
1433
1709
  this.channelHandlers.clear();
1434
1710
  this.channelFormatters.clear();
1711
+ this.channelNormalizers.clear();
1435
1712
  this.channelIntakeHandlers.clear();
1436
1713
  this.channelStates.clear();
1437
1714
  this.channelCalls.clear();
@@ -1475,6 +1752,7 @@ class ExtensionHostRuntime {
1475
1752
  this.commandHandlers.clear();
1476
1753
  this.channelHandlers.clear();
1477
1754
  this.channelFormatters.clear();
1755
+ this.channelNormalizers.clear();
1478
1756
  this.channelIntakeHandlers.clear();
1479
1757
  this.channelStates.clear();
1480
1758
  this.channelCalls.clear();
@@ -1495,6 +1773,7 @@ class ExtensionHostRuntime {
1495
1773
  }
1496
1774
  if (request.method === "channel.lifecycle") return this.deliverChannel(request.params);
1497
1775
  if (request.method === "channel.formatReply") return this.formatChannelReply(request.params);
1776
+ if (request.method === "channel.normalizeMessages") return this.normalizeChannelMessages(request.params);
1498
1777
  if (request.method === "channel.intake") return this.deliverChannelIntake(request.params);
1499
1778
  const handler = this.commandHandlers.get(request.params.commandId);
1500
1779
  if (!handler) {
@@ -1507,7 +1786,7 @@ class ExtensionHostRuntime {
1507
1786
  }
1508
1787
  async deliverChannel(event) {
1509
1788
  const contribution = this.options.manifest.contributes.channels?.find((channel) => channel.id === event.channelId);
1510
- if (this.options.manifest.channelApiVersion !== 1 || !contribution || contribution.provider !== event.provider) {
1789
+ if (!isSupportedChannelApiVersion(this.options.manifest.channelApiVersion) || !contribution || contribution.provider !== event.provider) {
1511
1790
  throw { code: "CHANNEL_NOT_DECLARED", message: "channel contribution/provider is not declared" };
1512
1791
  }
1513
1792
  const key = JSON.stringify([event.channelId, event.connectionId]);
@@ -1553,16 +1832,29 @@ class ExtensionHostRuntime {
1553
1832
  return null;
1554
1833
  }
1555
1834
  async formatChannelReply(input) {
1556
- const key = JSON.stringify([input.channelId, input.connectionId]);
1557
- const current = this.channelStates.get(key);
1558
- const epoch = this.channelEpoch;
1835
+ const isCurrent = this.channelCallIsCurrent(input);
1559
1836
  const formatter = this.channelFormatters.get(input.channelId);
1560
- const isCurrent = () => !this.disposed && !this.deactivating && this.active && epoch === this.channelEpoch && this.channelStates.get(key) === current && current?.state === "start" && current.provider === input.provider && current.connectionRevision === input.connectionRevision;
1561
1837
  if (!isCurrent() || !formatter) throw { code: "CHANNEL_FORMAT_UNAVAILABLE", message: "channel formatter is not active" };
1562
1838
  const result = await formatter(Object.freeze({ ...input }));
1563
1839
  if (!isCurrent()) throw { code: "CHANNEL_FORMAT_WITHDRAWN", message: "channel formatter stopped" };
1840
+ if (input.apiVersion === 2 && input.mode === "rendered") return parseChannelRenderedReplyResult(result);
1564
1841
  return parseChannelReplyFormatResult(result, input);
1565
1842
  }
1843
+ async normalizeChannelMessages(input) {
1844
+ const isCurrent = this.channelCallIsCurrent(input);
1845
+ const normalizer = this.channelNormalizers.get(input.channelId);
1846
+ if (!isCurrent() || !normalizer) throw { code: "CHANNEL_NORMALIZE_UNAVAILABLE", message: "channel normalizer is not active" };
1847
+ const result = await normalizer(Object.freeze({ ...input }));
1848
+ if (!isCurrent()) throw { code: "CHANNEL_NORMALIZE_WITHDRAWN", message: "channel normalizer stopped" };
1849
+ return { items: parseChannelNormalizeResult(input, result) };
1850
+ }
1851
+ /** A formatter or normalizer answers only for the started connection generation it was asked about. */
1852
+ channelCallIsCurrent(input) {
1853
+ const key = JSON.stringify([input.channelId, input.connectionId]);
1854
+ const current = this.channelStates.get(key);
1855
+ const epoch = this.channelEpoch;
1856
+ return () => !this.disposed && !this.deactivating && this.active && epoch === this.channelEpoch && this.channelStates.get(key) === current && current?.state === "start" && current.provider === input.provider && current.connectionRevision === input.connectionRevision;
1857
+ }
1566
1858
  async withTimeout(operation) {
1567
1859
  let timer;
1568
1860
  const timeout = new Promise((resolve2) => {
@@ -1596,6 +1888,7 @@ class ExtensionHostRuntime {
1596
1888
  const registered = /* @__PURE__ */ new Map();
1597
1889
  const registeredChannels = /* @__PURE__ */ new Map();
1598
1890
  const registeredFormatters = /* @__PURE__ */ new Map();
1891
+ const registeredNormalizers = /* @__PURE__ */ new Map();
1599
1892
  const registeredIntake = /* @__PURE__ */ new Map();
1600
1893
  await loaded.activate({
1601
1894
  extensionId: this.options.manifest.id,
@@ -1605,19 +1898,26 @@ class ExtensionHostRuntime {
1605
1898
  } },
1606
1899
  channels: {
1607
1900
  register: (channelId, handler) => {
1608
- if (!this.activating || this.options.manifest.channelApiVersion !== 1 || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof handler !== "function" || registeredChannels.has(channelId)) {
1901
+ if (!this.activating || !isSupportedChannelApiVersion(this.options.manifest.channelApiVersion) || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof handler !== "function" || registeredChannels.has(channelId)) {
1609
1902
  throw { code: "INVALID_CHANNEL_REGISTRATION", message: "channel must be declared and registered once" };
1610
1903
  }
1611
1904
  registeredChannels.set(channelId, handler);
1612
1905
  },
1613
1906
  registerFormatter: (channelId, formatter) => {
1614
- if (!this.activating || this.options.manifest.channelApiVersion !== 1 || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof formatter !== "function" || registeredFormatters.has(channelId)) {
1907
+ if (!this.activating || !isSupportedChannelApiVersion(this.options.manifest.channelApiVersion) || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof formatter !== "function" || registeredFormatters.has(channelId)) {
1615
1908
  throw { code: "INVALID_CHANNEL_REGISTRATION", message: "formatter must be declared and registered once" };
1616
1909
  }
1617
1910
  registeredFormatters.set(channelId, formatter);
1618
1911
  },
1912
+ registerNormalizer: (channelId, normalizer) => {
1913
+ const manifest = this.options.manifest;
1914
+ if (!this.activating || (manifest.channelApiVersion ?? 0) < 2 || !manifest.permissions.includes("channels.normalize") || !manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof normalizer !== "function" || registeredNormalizers.has(channelId)) {
1915
+ throw { code: "INVALID_CHANNEL_REGISTRATION", message: "normalizer needs channel API 2, channels.normalize and one registration" };
1916
+ }
1917
+ registeredNormalizers.set(channelId, normalizer);
1918
+ },
1619
1919
  onIntake: (channelId, handler) => {
1620
- if (!this.activating || this.options.manifest.channelApiVersion !== 1 || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof handler !== "function" || registeredIntake.has(channelId)) {
1920
+ if (!this.activating || !isSupportedChannelApiVersion(this.options.manifest.channelApiVersion) || !this.options.manifest.contributes.channels?.some((channel) => channel.id === channelId) || typeof handler !== "function" || registeredIntake.has(channelId)) {
1621
1921
  throw { code: "INVALID_CHANNEL_REGISTRATION", message: "intake handler must be declared and registered once" };
1622
1922
  }
1623
1923
  registeredIntake.set(channelId, handler);
@@ -1639,7 +1939,9 @@ class ExtensionHostRuntime {
1639
1939
  for (const [id2, render] of registeredWidgets) this.widgets.set(id2, render);
1640
1940
  this.channelHandlers.clear();
1641
1941
  this.channelFormatters.clear();
1942
+ this.channelNormalizers.clear();
1642
1943
  for (const [id2, formatter] of registeredFormatters) this.channelFormatters.set(id2, formatter);
1944
+ for (const [id2, normalizer] of registeredNormalizers) this.channelNormalizers.set(id2, normalizer);
1643
1945
  this.channelIntakeHandlers.clear();
1644
1946
  for (const [id2, handler] of registeredIntake) this.channelIntakeHandlers.set(id2, handler);
1645
1947
  for (const [id2, handler] of registeredChannels) this.channelHandlers.set(id2, handler);