@sema-agent/client-core 0.11.16 → 0.11.18

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
@@ -23,7 +23,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
23
23
 
24
24
  ## Scope
25
25
 
26
- **Version:** 0.11.16
26
+ **Version:** 0.11.18
27
27
 
28
28
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
29
29
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
package/dist/adapt.js CHANGED
@@ -705,6 +705,11 @@ class WireToCcAdapterImpl {
705
705
  // 代价是失去 for-await 的隐式 cleanup ⇒ finally 里手动 return()(T58,少了它 = SSE 不拆)。
706
706
  const iterator = frames[Symbol.asyncIterator]();
707
707
  let iteratorDone = false;
708
+ // R1(clay 五报 07-31):retry 覆盖层的四个清除点全在 turn 边界,缺「重试成功→清」的转移 ——
709
+ // 引擎重试成功那一刻什么都不发(BrainStatusPhase 无恢复终态,候 core [2163]),所以恢复只能由
710
+ // 「主 lane 又有内容在流」这个事实来证明。挂旗后首个主 lane 内容帧即清,CC 同位 = 重试循环
711
+ // 退出即 onRetryStatus(null)(pretty220 finally 清)。
712
+ let retryOverlayActive = false;
708
713
  try {
709
714
  for (;;) {
710
715
  const nextPromise = iterator.next();
@@ -755,6 +760,19 @@ class WireToCcAdapterImpl {
755
760
  const m = rawFrame;
756
761
  if (m === null || typeof m !== 'object')
757
762
  continue;
763
+ if (retryOverlayActive &&
764
+ (m.type === 'stream_event' ||
765
+ m.type === 'assistant' ||
766
+ m.type === 'tool_end_result' ||
767
+ m.type === 'turn_usage') &&
768
+ typeof m.parentToolCallId !== 'string' &&
769
+ // stream_event 的子流标记在 event 里(:1230 thinking 臂同款判位),帧顶层没有。
770
+ typeof m.event?.parentToolCallId !== 'string') {
771
+ // 恢复即清:主 lane 内容帧到达 = 引擎那次重试已成功;子流帧(parentToolCallId)不算证据
772
+ // (主 brain 仍可能在退避等待,#47 矩阵 #5 同族判别)。
773
+ retryOverlayActive = false;
774
+ yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
775
+ }
758
776
  if (m.type === 'stream_event' && !messageStartEmitted) {
759
777
  messageStartEmitted = true;
760
778
  yield chrome({
@@ -1362,6 +1380,7 @@ class WireToCcAdapterImpl {
1362
1380
  ...(typeof m.retryInSec === 'number' ? { retryInSec: m.retryInSec } : {}),
1363
1381
  }, ctx.now()),
1364
1382
  });
1383
+ retryOverlayActive = true;
1365
1384
  break;
1366
1385
  }
1367
1386
  default:
@@ -88,6 +88,12 @@ export type EngineAgentPanelEvent = {
88
88
  * 修「详情页 Prompt 段恒空」。缺席 ⇒ 维持现状(诚实留白)。
89
89
  */
90
90
  transcriptId?: string;
91
+ /**
92
+ * server 1.250 起行帧带的 launch 真锚(epoch ms)。R4(clay 五报 07-31):此前投影层丢弃它,
93
+ * 详情页 elapsed 只能锚「壳建行时刻」(createTaskStateBase 的 Date.now())——遗留/重挂行上
94
+ * 渲出 1h+ 假时长。消费端拿到即覆盖行的 startTime;缺席 ⇒ 老引擎,维持现状。
95
+ */
96
+ startedAt?: number;
91
97
  } | {
92
98
  kind: 'end';
93
99
  taskId: string;
@@ -17,6 +17,9 @@ export interface FleetAgentRowLike {
17
17
  toolName: string;
18
18
  target?: string;
19
19
  };
20
+ /** server 1.250 起行带的 launch 真锚(epoch ms)。R4(clay 五报 07-31):此前投影载荷丢弃它,
21
+ * 详情页 elapsed 只能锚「壳建行时刻」——遗留行上渲出 1h+ 的假时长。缺席=老引擎,消费端保持现状。 */
22
+ startedAt?: number;
20
23
  }
21
24
  /** 行从投影集消失多久之后按 completed 兜底 settle(重连空窗 / snapshot 清表远小于此)。 */
22
25
  export declare const ABSENT_SETTLE_MS = 30000;
@@ -80,6 +80,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
80
80
  // 🔴 三态直取:`row.toolUses` 缺席就是 undefined,这里**不许**兜 0(见 FleetAgentRowLike 注)。
81
81
  const toolUses = typeof row.toolUses === 'number' ? row.toolUses : undefined;
82
82
  const transcriptId = row.transcriptId || undefined;
83
+ const startedAt = typeof row.startedAt === 'number' && row.startedAt > 0 ? row.startedAt : undefined;
83
84
  const currentTool = currentToolKeyOf(row.currentTool) !== undefined ? row.currentTool : undefined;
84
85
  const currentToolKey = currentToolKeyOf(row.currentTool);
85
86
  const prev = seen.get(taskId);
@@ -89,7 +90,8 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
89
90
  // 终态行也把最后一拍的累计工具数/转录锚带上:详情页在行翻终态后仍要显示真计数与
90
91
  // 委派 prompt,而终态帧常常是这一行**唯一**带全 usage 的一帧(bg lane 只在活动边界发行帧)。
91
92
  if ((toolUses !== undefined && prev?.toolUses !== toolUses) ||
92
- (transcriptId !== undefined && prev?.transcriptId !== transcriptId)) {
93
+ (transcriptId !== undefined && prev?.transcriptId !== transcriptId) ||
94
+ (startedAt !== undefined && prev?.startedAt !== startedAt)) {
93
95
  publishEngineAgentPanelEvent({
94
96
  kind: 'fleet-row',
95
97
  taskId,
@@ -98,6 +100,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
98
100
  totalTokens: tokens,
99
101
  ...(toolUses !== undefined ? { toolUses } : {}),
100
102
  ...(transcriptId !== undefined ? { transcriptId } : {}),
103
+ ...(startedAt !== undefined ? { startedAt } : {}),
101
104
  });
102
105
  }
103
106
  publishEngineAgentPanelEvent({
@@ -111,6 +114,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
111
114
  tokens,
112
115
  toolUses: toolUses !== undefined ? toolUses : prev?.toolUses,
113
116
  transcriptId: transcriptId !== undefined ? transcriptId : prev?.transcriptId,
117
+ startedAt: startedAt !== undefined ? startedAt : prev?.startedAt,
114
118
  currentToolKey: currentToolKey !== undefined ? currentToolKey : prev?.currentToolKey,
115
119
  lastSeenAt: nowMs,
116
120
  settled: true,
@@ -127,6 +131,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
127
131
  prev.tokens !== tokens ||
128
132
  (toolUses !== undefined && prev.toolUses !== toolUses) ||
129
133
  (transcriptId !== undefined && prev.transcriptId !== transcriptId) ||
134
+ (startedAt !== undefined && prev.startedAt !== startedAt) ||
130
135
  (currentToolKey !== undefined && prev.currentToolKey !== currentToolKey)) {
131
136
  publishEngineAgentPanelEvent({
132
137
  kind: 'fleet-row',
@@ -137,6 +142,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
137
142
  // 🔴 缺席即不落键(消费端据「键在不在」判三态)。
138
143
  ...(toolUses !== undefined ? { toolUses } : {}),
139
144
  ...(transcriptId !== undefined ? { transcriptId } : {}),
145
+ ...(startedAt !== undefined ? { startedAt } : {}),
140
146
  ...(currentTool !== undefined ? { currentTool } : {}),
141
147
  });
142
148
  }
@@ -146,6 +152,7 @@ export function projectFleetAgentRows(rows, nowMs = Date.now()) {
146
152
  tokens,
147
153
  toolUses: toolUses !== undefined ? toolUses : prev?.toolUses,
148
154
  transcriptId: transcriptId !== undefined ? transcriptId : prev?.transcriptId,
155
+ startedAt: startedAt !== undefined ? startedAt : prev?.startedAt,
149
156
  currentToolKey: currentToolKey !== undefined ? currentToolKey : prev?.currentToolKey,
150
157
  lastSeenAt: nowMs,
151
158
  settled: false,
@@ -2,7 +2,7 @@ import { HitlBridge } from './hitlBridge.js';
2
2
  import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
3
3
  import { hostLog } from '../host.js';
4
4
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
5
- import { isFsApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
5
+ import { isToolApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
6
6
  import { classifierDenyFromToolEnd } from '../classifierVerdictWire.js';
7
7
  // W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
8
8
  const hostSurfaceByKey = createSessionSlot();
@@ -69,8 +69,9 @@ function isAskGatePark(result) {
69
69
  typeof r.taskId === 'string' &&
70
70
  r.taskId.length > 0);
71
71
  }
72
- /** done 帧的 fs 写权限 park 形状([816] 放宽腿):status suspended + checkpointGate 指 fs 写工具
73
- * (Write/Edit/NotebookEdit)或一等 kind==='tool_approval'。Ask gate 恒先判(问答 overlay 原路)。 */
72
+ /** done 帧的工具审批 park 形状([816] 放宽腿 + [2150] Bash 半场):status suspended +
73
+ * checkpointGate 指 fs 写三件 / Bash / 一等 kind==='tool_approval'(超集谓词 isToolApprovalGate,
74
+ * decide 腿对两族同构、卡形归壳分发)。Ask gate 恒先判(问答 overlay 原路)。 */
74
75
  function isFsApprovalPark(result) {
75
76
  const r = result;
76
77
  return (!!r &&
@@ -78,7 +79,7 @@ function isFsApprovalPark(result) {
78
79
  typeof r.taskId === 'string' &&
79
80
  r.taskId.length > 0 &&
80
81
  !isAskTool(r.checkpointGate?.toolName) &&
81
- isFsApprovalGate(r.checkpointGate));
82
+ isToolApprovalGate(r.checkpointGate));
82
83
  }
83
84
  /** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
84
85
  * (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
@@ -477,7 +478,7 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
477
478
  park = { gate: 'ask' };
478
479
  break;
479
480
  }
480
- if (ev.gate && isFsApprovalGate(ev.gate) && realTaskId) {
481
+ if (ev.gate && isToolApprovalGate(ev.gate) && realTaskId) {
481
482
  park = { gate: 'fs' };
482
483
  break;
483
484
  }
@@ -6,6 +6,17 @@ export declare function isFsApprovalGate(gate: {
6
6
  toolName?: unknown;
7
7
  } | undefined | null): boolean;
8
8
  export declare function toolNameIsFsWrite(name: string): boolean;
9
+ /** shell 执行工具判定(Bash)。[2150] S1 半场:引擎对 Bash 发 durable park 时,fs 谓词不认 ⇒
10
+ * park 落「其余 gate 透传」= run 留 suspended、session 锁死(r1 E5 fail-stuck 实例)。 */
11
+ export declare function toolNameIsShellExec(name: string): boolean;
12
+ /** 工具审批 gate 判定(durable park 分诊用的**超集**谓词):一等 kind / fs 写三件 / shell 执行。
13
+ * decide 腿(approvals.decide)对两族同构;卡形分发归壳 ApprovalCardPort(toolForName 已含
14
+ * bash → BashPermissionRequest 映射,sema-cli a35e2b2)。`isFsApprovalGate` 保留原语义不动
15
+ * (公开面消费者按 fs 写理解它,把 Bash 塞进去是撒谎)。 */
16
+ export declare function isToolApprovalGate(gate: {
17
+ kind?: unknown;
18
+ toolName?: unknown;
19
+ } | undefined | null): boolean;
9
20
  export type FsApprovalOutcome = {
10
21
  kind: 'decided';
11
22
  gatedCallId?: string;
@@ -15,6 +15,20 @@ export function toolNameIsFsWrite(name) {
15
15
  const n = name.replace(/[\s_-]+/g, '').toLowerCase();
16
16
  return (n === 'write' || n === 'filewrite' || n === 'edit' || n === 'fileedit' || n === 'multiedit' || n === 'notebookedit');
17
17
  }
18
+ /** shell 执行工具判定(Bash)。[2150] S1 半场:引擎对 Bash 发 durable park 时,fs 谓词不认 ⇒
19
+ * park 落「其余 gate 透传」= run 留 suspended、session 锁死(r1 E5 fail-stuck 实例)。 */
20
+ export function toolNameIsShellExec(name) {
21
+ return name.replace(/[\s_-]+/g, '').toLowerCase() === 'bash';
22
+ }
23
+ /** 工具审批 gate 判定(durable park 分诊用的**超集**谓词):一等 kind / fs 写三件 / shell 执行。
24
+ * decide 腿(approvals.decide)对两族同构;卡形分发归壳 ApprovalCardPort(toolForName 已含
25
+ * bash → BashPermissionRequest 映射,sema-cli a35e2b2)。`isFsApprovalGate` 保留原语义不动
26
+ * (公开面消费者按 fs 写理解它,把 Bash 塞进去是撒谎)。 */
27
+ export function isToolApprovalGate(gate) {
28
+ if (isFsApprovalGate(gate))
29
+ return true;
30
+ return typeof gate?.toolName === 'string' && toolNameIsShellExec(gate.toolName);
31
+ }
18
32
  /** 结构等值(键序无关深比较)——updatedInput「真编辑过」判定用。zod parse 会产新引用与重排,
19
33
  * 引用比较/JSON.stringify 串比较都会假报「编辑过」。 */
20
34
  export function structurallyEqual(a, b) {
@@ -115,7 +129,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
115
129
  try {
116
130
  const rows = (await deps.client.approvals.list(signal ? { signal } : undefined)).pending;
117
131
  pending =
118
- rows.find(r => r.taskId === taskId && typeof r.toolName === 'string' && toolNameIsFsWrite(r.toolName)) ??
132
+ rows.find(r => r.taskId === taskId && typeof r.toolName === 'string' && (toolNameIsFsWrite(r.toolName) || toolNameIsShellExec(r.toolName))) ??
119
133
  rows.find(r => r.taskId === taskId);
120
134
  }
121
135
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -220,3 +220,4 @@ export * from './request/printNotification.js';
220
220
  export * from './seatContract.js';
221
221
  export * from './agentSession/contract.js';
222
222
  export * from './agentSession/backgroundView.js';
223
+ export * from './model/catalog.js';
package/dist/index.js CHANGED
@@ -302,3 +302,11 @@ export * from './agentSession/contract.js';
302
302
  // 两源(assistant.tasks 跨 session 调度总览 + fleet 常驻 SSE 快照)独立拉取独立降级的归一投影;
303
303
  // 两张 status 映射表 never 卫兵穷举;'not-configured' 判别姿势见文件头注。
304
304
  export * from './agentSession/backgroundView.js';
305
+ // ── design/160 需求②:模型 provider 目录公用化(2026-07-31)──────────────────────────────────
306
+ // 三层源解析(线上 URL 可配 → 包内 providerPresets 兜底 → 用户自定义覆盖)+ **来源标注**。
307
+ // 🔴 零 Node、零全局 fetch:线上腿的取数走注入的 `CatalogFetchJson` 端口,宿主没给 ⇒ 线上腿
308
+ // 整条不启用(`online.reason = 'no-fetch-port'`),与 host.ts 的缺席语义同一条纪律。
309
+ // 🔴 本文件零默认 URL:目录仓位候裁(设计档待裁点 3),机制先行、地址由调用方传。
310
+ // 🔴 绝不半解析:schemaVersion 超区间/任一行形状坏 ⇒ 整份弃用走兜底(端渲「内置版本(离线)」
311
+ // 靠 `source` + `online.reason`,所以「静默用兜底」在本层是可观测的)。
312
+ export * from './model/catalog.js';
@@ -0,0 +1,158 @@
1
+ /**
2
+ * catalog.ts — **模型 provider 目录的三层源解析**(design/160 需求②,2026-07-31)。
3
+ *
4
+ * ## 它解决什么
5
+ *
6
+ * `providerPresets.json` 是**编译进包的静态表**:加一家 provider、改一个 model 的 cap,都得发一次
7
+ * client-core。而三端(CLI / web / 桌面)要的是同一份目录、可在线更新、离线仍能用。本模块把
8
+ * 设计档 §需求② 的三层源做成一个**纯解析器**:
9
+ *
10
+ * ```
11
+ * ① 线上目录(URL 由调用方传入 —— 本文件零默认地址,见下「为什么不写死 URL」)
12
+ * ↓ 拉取失败 / 离线 / 校验不过(整份弃用)
13
+ * ② 包内兜底 = PROVIDER_PRESETS(零改动即兜底)
14
+ * ↓
15
+ * ③ 用户本地覆盖(自定义 provider,合并语义:同 id 用户 > 线上 > 包内)
16
+ * ```
17
+ *
18
+ * ## 四条设计纪律(每条都在 pure 门 MC 段有断言)
19
+ *
20
+ * 1. **零 Node、零自发网络**:本文件不 import 任何 `node:*`,也**不碰全局 `fetch`** ——
21
+ * 网络能力由调用方注入 `fetchJson` 端口。宿主没给 ⇒ 线上腿整条不启用(`reason: 'no-fetch-port'`),
22
+ * 而不是「偷偷换个方式做」(与 host.ts 的缺席语义同一条纪律)。
23
+ * ⇒ 桌面渲染进程 / 浏览器 / TUI 用同一份代码,各自注入自己的传输。
24
+ * 2. **绝不半解析**(设计档明写):`schemaVersion` 超出支持区间、`providers` 不是数组、
25
+ * **任何一行**形状不合格 ⇒ **整份**线上目录弃用走兜底。半解析 = 用户拿到一张自己也说不清
26
+ * 是哪一版的表。
27
+ * 3. **来源标注是产品位不是调试位**:返回值恒带 `source` 与 `online.{ok,reason}`。端据此渲
28
+ * 「目录为内置版本(离线)」——「静默用兜底」在本层必须是**可观测**的,否则用户会以为看到的
29
+ * 是最新表([honest-absence-not-fabricated-zero])。
30
+ * 4. **诚实缺席**:`staleness` 只在真吃到线上目录时在场;`ageMs` 只在调用方给了 `nowMs`
31
+ * 且 `generatedAt` 可解析时在场。不知道就是键缺席,绝不 `?? 0`,也绝不偷读时钟
32
+ * (时钟是宿主资产,与 `AdapterContext.now` 同一条口径)。
33
+ *
34
+ * ## 为什么不写死线上 URL
35
+ *
36
+ * 目录仓位(设计档待裁点 3:`sema-agent/model-catalog` 公开仓 or release asset)**候 clay 裁**。
37
+ * 机制先行、地址后填:URL 由调用方从配置/settings 传入。本文件里出现任何默认地址,都会让
38
+ * 「还没定的仓位」提前变成事实契约。https-only 是硬校验(目录是会被下载并影响**出站地址**的
39
+ * 数据,明文信道下可被改写成钓鱼网关)。
40
+ *
41
+ * ## 本批不做(留口注释,别当遗漏)
42
+ *
43
+ * · **缓存与 ETag**:`~/.sema/cache/model-catalog.json` + ETag/304 + 新鲜窗 + `localGeneratedAt`
44
+ * 防降级(设计档 pi 调研节)。落盘是宿主能力(fs 在 host.ts 的 `FsPort`),所以缓存层应当是
45
+ * **本模块之上**的一层:调用方拿 `resolveModelCatalog` 的结果自行落盘,并把上次的 `generatedAt`
46
+ * 与新拉到的比较后决定是否替换。⇒ 加缓存时**不需要**改本文件的形状,只需在 `fetchJson` 注入
47
+ * 处做 ETag,并在外层加一个「旧远端压不过新包」的比较。
48
+ * · **签名 / sha256**:同上,属传输层与目录产出方的约定(设计档待裁点 3)。
49
+ * · **`oauth` / `subscribe` 字段**:设计档待裁点 3b。本批只加纯链接位 `consoleUrl`/`signupUrl`
50
+ * (见 providerPresets.ts),授权码流/设备码流一律不进。
51
+ *
52
+ * ## probe 复用面评估(设计档 §需求②-C「复用面」;只评估不搬,搬迁下一批做)
53
+ *
54
+ * 桌面端 onboard 要「选 provider → 填 key → 探活」,探活那一步现在只在壳树。按
55
+ * [multiclient-migration-batch-discipline](模块级状态与读写口同包)逐个评估,结论如下 ——
56
+ * 这是下一批的搬迁清单,坐标为壳树 `src/sema/onboard/`:
57
+ *
58
+ * | 文件 | 结论 | 依据 |
59
+ * |---|---|---|
60
+ * | `probePlan.ts`(139 行) | ✅ **整搬,零改造** | 零 import、零模块级状态、纯函数四件(`buildVerdictRequest` / `extractApiError` / `looksLikeModelError` / `judgeMessagesProbe`)。判决表本身就是「三端必须一致」的那部分。 |
61
+ * | `probeProvider.ts`(223 行) | ⚠️ **拆搬**:判定+请求编排进包、传输与 TLS 留端 | ①裸 `fetch`/`AbortController`/`setTimeout` ⇒ 改注入 `fetchImpl`(本包既有姿势:`limitsWire`/`detachWire`/`liveInitToolFace` 的 `opts.fetchImpl`);②`ensureProxyBootstrap()` 动态 import 壳的 `utils/proxy.js`(`configureGlobalAgents`)= **Node undici 专属**,浏览器无此概念 ⇒ 留端,做成可选的「拨号前置钩子」端口;③`isCertError`/`applySystemTlsTrust`(壳 `tlsTrust.ts`,读系统钥匙串 + 设 `NODE_EXTRA_CA_CERTS`)= 纯 Node ⇒ 留端,做成可选的「一次性重试钩子」;④`nearestListed()` 与 `unwrapFetchError()` 是纯函数 ⇒ 直接进包。搬后包内出口 ≈ `probeProvider({ baseUrl, api, apiKey, modelId }, { fetchImpl, beforeDial?, retryOnCertError? })`。 |
62
+ * | `detectSources.ts`(524 行) | ❌ **本批不搬** | 它整个是**宿主文件系统扫描器**:`node:fs`(existsSync/readdirSync/readFileSync/statSync)+ `node:os` homedir + `node:path` + `process.env` + 两个外部解析器(`jsonc-parser`/`yaml`)+ 壳内 `ccConfigFallback`。可搬的只有「读到的配置 → `ImportableProvider` 的分类/归一逻辑」那一小半,而它与扫描代码是逐行交织的。⇒ 要搬得先在壳里把「扫描(IO)」与「分类(纯)」拆开,是独立一批的量。**桌面端不阻塞**:桌面 onboard 的导入源探测本就该是主进程的活(与 CLI 同为 Node 侧),复用价值远低于 probe 两件。 |
63
+ * | `onboardZeroModel.ts`(2.8K) | 🔶 **顺路可搬** | 已有独立单测(`onboardZeroModel.test.ts`),看形状是纯判定;搬前需确认零壳态依赖。 |
64
+ * | `applyOnboard.ts` / `OnboardWizard.tsx` / `BigStepTitle.tsx` / `ccSwitchReader.ts` | ❌ 属端 | 落盘编排 / Ink 渲染 / 读 cc-switch sqlite —— 宿主辖区,桌面端各写各的渲染器(设计档 §C 与 pi 的 `AuthInteraction` seam 同一心智)。 |
65
+ *
66
+ * 搬迁批建议顺序:`probePlan`(零风险打头,先让包里有判决表)→ `probeProvider` 拆搬(带
67
+ * fetchImpl 注入 + 两个可选钩子端口)→ `onboardZeroModel` → detectSources 拆分单列一批。
68
+ */
69
+ import { type ProviderPreset } from './providerPresets.js';
70
+ /** 支持的目录 schemaVersion 区间(闭区间)。产目录那一方按这个区间发版。 */
71
+ export declare const CATALOG_SCHEMA_VERSION_MIN = 1;
72
+ export declare const CATALOG_SCHEMA_VERSION_MAX = 1;
73
+ /** 线上目录文档的信封形。`providers` 与包内表**同形**(ProviderPreset),所以两层可直接按 id 合并。 */
74
+ export interface OnlineCatalogDoc {
75
+ schemaVersion: number;
76
+ /** 目录生成时刻(ISO8601)。用于端渲新鲜度;不可解析也不弃用整份(它不是契约位)。 */
77
+ generatedAt: string;
78
+ providers: ProviderPreset[];
79
+ }
80
+ /**
81
+ * 结果的来源标注 = **最高优先级的在场层**:
82
+ * · `merged` —— 用户覆盖层真的贡献了行(不管线上腿成没成);
83
+ * · `online` —— 线上目录被接受且无用户覆盖;
84
+ * · `bundled` —— 只有包内表(离线 / 线上被拒 / 没配 URL)。
85
+ */
86
+ export type CatalogSource = 'online' | 'bundled' | 'merged';
87
+ /** 线上腿被拒的**具体**理由(端可据此分诊:是没配、是离线、还是目录本身坏了)。 */
88
+ export type CatalogRejectReason = 'no-online-url' | 'no-fetch-port' | 'insecure-url' | 'fetch-failed' | 'payload-not-object' | 'schema-version-missing' | 'schema-version-unsupported' | 'generated-at-missing' | 'providers-not-array' | 'provider-row-invalid';
89
+ /** 线上腿的结局。`ok: true` 时不带 `reason`(枚举互斥)。 */
90
+ export interface CatalogOnlineOutcome {
91
+ ok: boolean;
92
+ reason?: CatalogRejectReason;
93
+ /**
94
+ * 拒绝细节(人话,给分诊用)。🔴 只放判决相关的短语与被截断的错误 message,
95
+ * **绝不回显载荷本身**([credential-error-path-leak]:错误路径是凭证外泄的经典入口;
96
+ * 目录 URL 本身是公开物,但宿主注入的 fetchJson 可能带鉴权头,所以异常只取 message 前段)。
97
+ */
98
+ detail?: string;
99
+ }
100
+ /** 线上目录的新鲜度。只在真吃到线上目录时在场。 */
101
+ export interface CatalogStaleness {
102
+ /** 目录自述的生成时刻,原样透传(烂串也原样给端,由端决定怎么说)。 */
103
+ generatedAt: string;
104
+ /** 距今毫秒。只在调用方给了 `nowMs` 且 `generatedAt` 可解析时在场 —— 不知道就键缺席。 */
105
+ ageMs?: number;
106
+ }
107
+ export interface ModelCatalogResult {
108
+ /** 合并后的 provider 表(新数组;包内表绝不被就地改写)。 */
109
+ providers: ProviderPreset[];
110
+ source: CatalogSource;
111
+ staleness?: CatalogStaleness;
112
+ online: CatalogOnlineOutcome;
113
+ /** 用户覆盖层真正贡献的 provider id(替换的 + 新增的)。无覆盖 ⇒ 空数组。 */
114
+ overrideIds: string[];
115
+ }
116
+ /**
117
+ * 注入式取 JSON 端口 —— **本包唯一的线上目录取数口**。
118
+ * 约定:2xx ⇒ resolve 已解析的 JSON;其它一切(网络错 / 非 2xx / 解析失败)⇒ reject。
119
+ * ETag/304、超时、代理、鉴权头全归宿主(它们各端形态不同,见文件头「本批不做」)。
120
+ */
121
+ export type CatalogFetchJson = (url: string) => Promise<unknown>;
122
+ export interface ResolveModelCatalogOptions {
123
+ /** 线上目录 URL。缺席 ⇒ 线上腿不启用(本包零默认地址)。必须是 https。 */
124
+ onlineUrl?: string;
125
+ /** 取数口。缺席 ⇒ 线上腿不启用(本包绝不自己碰 fetch)。 */
126
+ fetchJson?: CatalogFetchJson;
127
+ /** 用户本地覆盖(自定义 provider)。同 id 恒赢线上与包内。 */
128
+ overrides?: readonly ProviderPreset[];
129
+ /** 当前时刻(宿主时钟)。缺席 ⇒ 不算 ageMs(而不是自己读 Date.now)。 */
130
+ nowMs?: number;
131
+ }
132
+ /** schemaVersion 是否落在支持区间内(非整数一律否)。 */
133
+ export declare function isSupportedCatalogSchemaVersion(v: unknown): boolean;
134
+ export type ValidateCatalogResult = {
135
+ ok: true;
136
+ doc: OnlineCatalogDoc;
137
+ } | {
138
+ ok: false;
139
+ reason: CatalogRejectReason;
140
+ detail?: string;
141
+ };
142
+ /**
143
+ * 校验线上载荷 —— **纯同步**,端可以单用(比如「刷新目录」按钮先本地校验再落盘)。
144
+ * 🔴 全或无:任何一条不合格 ⇒ 整份拒,绝不半解析。
145
+ */
146
+ export declare function validateOnlineCatalog(payload: unknown): ValidateCatalogResult;
147
+ /**
148
+ * 按 id 分层合并:**后来的层赢**,行序 = 首见序(包内序稳定在前,新家按层依次追加)。
149
+ * 纯函数,端可以单用(比如只合并「包内 + 用户覆盖」的离线路径)。
150
+ */
151
+ export declare function mergeProviderLayers<T extends {
152
+ id: string;
153
+ }>(...layers: readonly (readonly T[])[]): T[];
154
+ /**
155
+ * 解析出一份可用的 provider 目录 —— 三层源 + 来源标注。**永不 reject**:线上腿的任何失败都
156
+ * 收敛成 `online.ok === false` 并回落包内表(离线可用是硬需求)。
157
+ */
158
+ export declare function resolveModelCatalog(opts?: ResolveModelCatalogOptions): Promise<ModelCatalogResult>;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * catalog.ts — **模型 provider 目录的三层源解析**(design/160 需求②,2026-07-31)。
3
+ *
4
+ * ## 它解决什么
5
+ *
6
+ * `providerPresets.json` 是**编译进包的静态表**:加一家 provider、改一个 model 的 cap,都得发一次
7
+ * client-core。而三端(CLI / web / 桌面)要的是同一份目录、可在线更新、离线仍能用。本模块把
8
+ * 设计档 §需求② 的三层源做成一个**纯解析器**:
9
+ *
10
+ * ```
11
+ * ① 线上目录(URL 由调用方传入 —— 本文件零默认地址,见下「为什么不写死 URL」)
12
+ * ↓ 拉取失败 / 离线 / 校验不过(整份弃用)
13
+ * ② 包内兜底 = PROVIDER_PRESETS(零改动即兜底)
14
+ * ↓
15
+ * ③ 用户本地覆盖(自定义 provider,合并语义:同 id 用户 > 线上 > 包内)
16
+ * ```
17
+ *
18
+ * ## 四条设计纪律(每条都在 pure 门 MC 段有断言)
19
+ *
20
+ * 1. **零 Node、零自发网络**:本文件不 import 任何 `node:*`,也**不碰全局 `fetch`** ——
21
+ * 网络能力由调用方注入 `fetchJson` 端口。宿主没给 ⇒ 线上腿整条不启用(`reason: 'no-fetch-port'`),
22
+ * 而不是「偷偷换个方式做」(与 host.ts 的缺席语义同一条纪律)。
23
+ * ⇒ 桌面渲染进程 / 浏览器 / TUI 用同一份代码,各自注入自己的传输。
24
+ * 2. **绝不半解析**(设计档明写):`schemaVersion` 超出支持区间、`providers` 不是数组、
25
+ * **任何一行**形状不合格 ⇒ **整份**线上目录弃用走兜底。半解析 = 用户拿到一张自己也说不清
26
+ * 是哪一版的表。
27
+ * 3. **来源标注是产品位不是调试位**:返回值恒带 `source` 与 `online.{ok,reason}`。端据此渲
28
+ * 「目录为内置版本(离线)」——「静默用兜底」在本层必须是**可观测**的,否则用户会以为看到的
29
+ * 是最新表([honest-absence-not-fabricated-zero])。
30
+ * 4. **诚实缺席**:`staleness` 只在真吃到线上目录时在场;`ageMs` 只在调用方给了 `nowMs`
31
+ * 且 `generatedAt` 可解析时在场。不知道就是键缺席,绝不 `?? 0`,也绝不偷读时钟
32
+ * (时钟是宿主资产,与 `AdapterContext.now` 同一条口径)。
33
+ *
34
+ * ## 为什么不写死线上 URL
35
+ *
36
+ * 目录仓位(设计档待裁点 3:`sema-agent/model-catalog` 公开仓 or release asset)**候 clay 裁**。
37
+ * 机制先行、地址后填:URL 由调用方从配置/settings 传入。本文件里出现任何默认地址,都会让
38
+ * 「还没定的仓位」提前变成事实契约。https-only 是硬校验(目录是会被下载并影响**出站地址**的
39
+ * 数据,明文信道下可被改写成钓鱼网关)。
40
+ *
41
+ * ## 本批不做(留口注释,别当遗漏)
42
+ *
43
+ * · **缓存与 ETag**:`~/.sema/cache/model-catalog.json` + ETag/304 + 新鲜窗 + `localGeneratedAt`
44
+ * 防降级(设计档 pi 调研节)。落盘是宿主能力(fs 在 host.ts 的 `FsPort`),所以缓存层应当是
45
+ * **本模块之上**的一层:调用方拿 `resolveModelCatalog` 的结果自行落盘,并把上次的 `generatedAt`
46
+ * 与新拉到的比较后决定是否替换。⇒ 加缓存时**不需要**改本文件的形状,只需在 `fetchJson` 注入
47
+ * 处做 ETag,并在外层加一个「旧远端压不过新包」的比较。
48
+ * · **签名 / sha256**:同上,属传输层与目录产出方的约定(设计档待裁点 3)。
49
+ * · **`oauth` / `subscribe` 字段**:设计档待裁点 3b。本批只加纯链接位 `consoleUrl`/`signupUrl`
50
+ * (见 providerPresets.ts),授权码流/设备码流一律不进。
51
+ *
52
+ * ## probe 复用面评估(设计档 §需求②-C「复用面」;只评估不搬,搬迁下一批做)
53
+ *
54
+ * 桌面端 onboard 要「选 provider → 填 key → 探活」,探活那一步现在只在壳树。按
55
+ * [multiclient-migration-batch-discipline](模块级状态与读写口同包)逐个评估,结论如下 ——
56
+ * 这是下一批的搬迁清单,坐标为壳树 `src/sema/onboard/`:
57
+ *
58
+ * | 文件 | 结论 | 依据 |
59
+ * |---|---|---|
60
+ * | `probePlan.ts`(139 行) | ✅ **整搬,零改造** | 零 import、零模块级状态、纯函数四件(`buildVerdictRequest` / `extractApiError` / `looksLikeModelError` / `judgeMessagesProbe`)。判决表本身就是「三端必须一致」的那部分。 |
61
+ * | `probeProvider.ts`(223 行) | ⚠️ **拆搬**:判定+请求编排进包、传输与 TLS 留端 | ①裸 `fetch`/`AbortController`/`setTimeout` ⇒ 改注入 `fetchImpl`(本包既有姿势:`limitsWire`/`detachWire`/`liveInitToolFace` 的 `opts.fetchImpl`);②`ensureProxyBootstrap()` 动态 import 壳的 `utils/proxy.js`(`configureGlobalAgents`)= **Node undici 专属**,浏览器无此概念 ⇒ 留端,做成可选的「拨号前置钩子」端口;③`isCertError`/`applySystemTlsTrust`(壳 `tlsTrust.ts`,读系统钥匙串 + 设 `NODE_EXTRA_CA_CERTS`)= 纯 Node ⇒ 留端,做成可选的「一次性重试钩子」;④`nearestListed()` 与 `unwrapFetchError()` 是纯函数 ⇒ 直接进包。搬后包内出口 ≈ `probeProvider({ baseUrl, api, apiKey, modelId }, { fetchImpl, beforeDial?, retryOnCertError? })`。 |
62
+ * | `detectSources.ts`(524 行) | ❌ **本批不搬** | 它整个是**宿主文件系统扫描器**:`node:fs`(existsSync/readdirSync/readFileSync/statSync)+ `node:os` homedir + `node:path` + `process.env` + 两个外部解析器(`jsonc-parser`/`yaml`)+ 壳内 `ccConfigFallback`。可搬的只有「读到的配置 → `ImportableProvider` 的分类/归一逻辑」那一小半,而它与扫描代码是逐行交织的。⇒ 要搬得先在壳里把「扫描(IO)」与「分类(纯)」拆开,是独立一批的量。**桌面端不阻塞**:桌面 onboard 的导入源探测本就该是主进程的活(与 CLI 同为 Node 侧),复用价值远低于 probe 两件。 |
63
+ * | `onboardZeroModel.ts`(2.8K) | 🔶 **顺路可搬** | 已有独立单测(`onboardZeroModel.test.ts`),看形状是纯判定;搬前需确认零壳态依赖。 |
64
+ * | `applyOnboard.ts` / `OnboardWizard.tsx` / `BigStepTitle.tsx` / `ccSwitchReader.ts` | ❌ 属端 | 落盘编排 / Ink 渲染 / 读 cc-switch sqlite —— 宿主辖区,桌面端各写各的渲染器(设计档 §C 与 pi 的 `AuthInteraction` seam 同一心智)。 |
65
+ *
66
+ * 搬迁批建议顺序:`probePlan`(零风险打头,先让包里有判决表)→ `probeProvider` 拆搬(带
67
+ * fetchImpl 注入 + 两个可选钩子端口)→ `onboardZeroModel` → detectSources 拆分单列一批。
68
+ */
69
+ import { PROVIDER_PRESETS } from './providerPresets.js';
70
+ /** 支持的目录 schemaVersion 区间(闭区间)。产目录那一方按这个区间发版。 */
71
+ export const CATALOG_SCHEMA_VERSION_MIN = 1;
72
+ export const CATALOG_SCHEMA_VERSION_MAX = 1;
73
+ /** schemaVersion 是否落在支持区间内(非整数一律否)。 */
74
+ export function isSupportedCatalogSchemaVersion(v) {
75
+ return (typeof v === 'number' &&
76
+ Number.isInteger(v) &&
77
+ v >= CATALOG_SCHEMA_VERSION_MIN &&
78
+ v <= CATALOG_SCHEMA_VERSION_MAX);
79
+ }
80
+ function isProviderRow(v) {
81
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
82
+ return false;
83
+ const r = v;
84
+ return (typeof r.id === 'string' &&
85
+ r.id.length > 0 &&
86
+ typeof r.name === 'string' &&
87
+ typeof r.baseUrl === 'string' &&
88
+ (r.api === 'anthropic-messages' || r.api === 'openai-completions') &&
89
+ typeof r.authEnv === 'string' &&
90
+ Array.isArray(r.models));
91
+ }
92
+ /**
93
+ * 校验线上载荷 —— **纯同步**,端可以单用(比如「刷新目录」按钮先本地校验再落盘)。
94
+ * 🔴 全或无:任何一条不合格 ⇒ 整份拒,绝不半解析。
95
+ */
96
+ export function validateOnlineCatalog(payload) {
97
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
98
+ return { ok: false, reason: 'payload-not-object', detail: 'catalog payload is not a JSON object' };
99
+ }
100
+ const doc = payload;
101
+ if (typeof doc.schemaVersion !== 'number' || !Number.isInteger(doc.schemaVersion)) {
102
+ return { ok: false, reason: 'schema-version-missing', detail: 'schemaVersion missing or not an integer' };
103
+ }
104
+ if (!isSupportedCatalogSchemaVersion(doc.schemaVersion)) {
105
+ return {
106
+ ok: false,
107
+ reason: 'schema-version-unsupported',
108
+ detail: `schemaVersion ${doc.schemaVersion} outside supported range ${CATALOG_SCHEMA_VERSION_MIN}..${CATALOG_SCHEMA_VERSION_MAX}`,
109
+ };
110
+ }
111
+ if (typeof doc.generatedAt !== 'string' || doc.generatedAt.length === 0) {
112
+ return { ok: false, reason: 'generated-at-missing', detail: 'generatedAt missing' };
113
+ }
114
+ if (!Array.isArray(doc.providers)) {
115
+ return { ok: false, reason: 'providers-not-array', detail: 'providers is not an array' };
116
+ }
117
+ const bad = doc.providers.findIndex((row) => !isProviderRow(row));
118
+ if (bad >= 0) {
119
+ return {
120
+ ok: false,
121
+ reason: 'provider-row-invalid',
122
+ // 只报**位置**不报内容:载荷是外部数据,回显 = 把不可信内容带进日志面。
123
+ detail: `provider row #${bad} failed the shape gate (whole document rejected — no partial parse)`,
124
+ };
125
+ }
126
+ return {
127
+ ok: true,
128
+ doc: {
129
+ schemaVersion: doc.schemaVersion,
130
+ generatedAt: doc.generatedAt,
131
+ providers: doc.providers,
132
+ },
133
+ };
134
+ }
135
+ /**
136
+ * 按 id 分层合并:**后来的层赢**,行序 = 首见序(包内序稳定在前,新家按层依次追加)。
137
+ * 纯函数,端可以单用(比如只合并「包内 + 用户覆盖」的离线路径)。
138
+ */
139
+ export function mergeProviderLayers(...layers) {
140
+ const order = [];
141
+ const byId = new Map();
142
+ for (const layer of layers) {
143
+ for (const row of layer) {
144
+ if (!row || typeof row.id !== 'string' || row.id.length === 0)
145
+ continue;
146
+ if (!byId.has(row.id))
147
+ order.push(row.id);
148
+ byId.set(row.id, row);
149
+ }
150
+ }
151
+ const out = [];
152
+ for (const id of order) {
153
+ const row = byId.get(id);
154
+ if (row !== undefined)
155
+ out.push(row);
156
+ }
157
+ return out;
158
+ }
159
+ /** https-only(目录会影响出站地址,明文信道可被改写成钓鱼网关)。 */
160
+ function isHttpsUrl(u) {
161
+ return /^https:\/\/\S+$/i.test(u.trim());
162
+ }
163
+ /** 异常 → 短 detail(只取 message 前段,绝不带栈、绝不带载荷)。 */
164
+ function shortError(e) {
165
+ const msg = e instanceof Error ? e.message : String(e);
166
+ return msg.slice(0, 160);
167
+ }
168
+ /**
169
+ * 解析出一份可用的 provider 目录 —— 三层源 + 来源标注。**永不 reject**:线上腿的任何失败都
170
+ * 收敛成 `online.ok === false` 并回落包内表(离线可用是硬需求)。
171
+ */
172
+ export async function resolveModelCatalog(opts) {
173
+ const overrides = opts?.overrides ?? [];
174
+ let onlineDoc = null;
175
+ let online;
176
+ const url = opts?.onlineUrl?.trim();
177
+ if (!url) {
178
+ online = { ok: false, reason: 'no-online-url', detail: 'no online catalog URL configured' };
179
+ }
180
+ else if (typeof opts?.fetchJson !== 'function') {
181
+ online = { ok: false, reason: 'no-fetch-port', detail: 'host did not install a fetchJson port' };
182
+ }
183
+ else if (!isHttpsUrl(url)) {
184
+ // 🔴 校验在拨号**之前**:非 https 连请求都不发。
185
+ online = { ok: false, reason: 'insecure-url', detail: 'online catalog URL must be https' };
186
+ }
187
+ else {
188
+ // 单发不重试:重试/超时预算属宿主传输层(注入的 fetchJson 自己带)。
189
+ let payload;
190
+ let dialFailure = null;
191
+ try {
192
+ payload = await opts.fetchJson(url);
193
+ }
194
+ catch (e) {
195
+ dialFailure = { ok: false, reason: 'fetch-failed', detail: shortError(e) };
196
+ }
197
+ if (dialFailure !== null) {
198
+ online = dialFailure;
199
+ }
200
+ else {
201
+ const v = validateOnlineCatalog(payload);
202
+ if (v.ok) {
203
+ onlineDoc = v.doc;
204
+ online = { ok: true };
205
+ }
206
+ else {
207
+ online = { ok: false, reason: v.reason, ...(v.detail !== undefined ? { detail: v.detail } : {}) };
208
+ }
209
+ }
210
+ }
211
+ const providers = mergeProviderLayers(PROVIDER_PRESETS, onlineDoc?.providers ?? [], overrides);
212
+ const overrideIds = mergeProviderLayers(overrides).map((p) => p.id);
213
+ const source = overrideIds.length > 0 ? 'merged' : onlineDoc !== null ? 'online' : 'bundled';
214
+ const result = { providers, source, online, overrideIds };
215
+ if (onlineDoc !== null) {
216
+ const parsed = Date.parse(onlineDoc.generatedAt);
217
+ const ageMs = opts?.nowMs !== undefined && Number.isFinite(parsed) ? opts.nowMs - parsed : undefined;
218
+ result.staleness = {
219
+ generatedAt: onlineDoc.generatedAt,
220
+ ...(ageMs !== undefined ? { ageMs } : {}),
221
+ };
222
+ }
223
+ return result;
224
+ }
@@ -14,6 +14,17 @@ export type ProviderPreset = {
14
14
  models: PresetModel[];
15
15
  /** Aggregator gateways (OpenRouter): model id is hand-typed, ctx/maxtok via family table. */
16
16
  modelViaFamily?: boolean;
17
+ /**
18
+ * 🆕 design/160 需求②-B(2026-07-31):**去哪拿 key** 的控制台页(纯链接,离线也能显示)。
19
+ *
20
+ * 🔴 **诚实缺席**:只填能可靠确认的官网控制台地址;拿不准的家**不填**,绝不编 URL —— 这个字段
21
+ * 会被 onboard 直接拿去开浏览器,编一个 = 把用户送到错误的地方。本地/自建家(ollama /
22
+ * lmstudio / vllm / sglang / litellm)按定义没有控制台页,缺席是正确答案不是遗漏。
23
+ * 设计档待裁点 3b:`oauth` / `subscribe` 两类**本批不加**(授权码流/设备码流单独排)。
24
+ */
25
+ consoleUrl?: string;
26
+ /** 没账号时的注册入口。与 `consoleUrl` 同页的家不重复填(缺席 = 用 consoleUrl 那条)。 */
27
+ signupUrl?: string;
17
28
  };
18
29
  export type ModelFamily = {
19
30
  id: string;
@@ -6,6 +6,8 @@
6
6
  "baseUrl": "https://api.anthropic.com",
7
7
  "api": "anthropic-messages",
8
8
  "authEnv": "ANTHROPIC_API_KEY",
9
+ "consoleUrl": "https://console.anthropic.com/settings/keys",
10
+ "signupUrl": "https://console.anthropic.com/",
9
11
  "models": [
10
12
  {
11
13
  "id": "claude-fable-5",
@@ -53,6 +55,8 @@
53
55
  "baseUrl": "https://api.openai.com/v1",
54
56
  "api": "openai-completions",
55
57
  "authEnv": "OPENAI_API_KEY",
58
+ "consoleUrl": "https://platform.openai.com/api-keys",
59
+ "signupUrl": "https://platform.openai.com/signup",
56
60
  "models": [
57
61
  {
58
62
  "id": "gpt-5.5",
@@ -106,6 +110,8 @@
106
110
  "baseUrl": "https://api.deepseek.com/v1",
107
111
  "api": "openai-completions",
108
112
  "authEnv": "DEEPSEEK_API_KEY",
113
+ "consoleUrl": "https://platform.deepseek.com/api_keys",
114
+ "signupUrl": "https://platform.deepseek.com/sign_up",
109
115
  "models": [
110
116
  {
111
117
  "id": "deepseek-v4-pro",
@@ -137,6 +143,7 @@
137
143
  "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
138
144
  "api": "openai-completions",
139
145
  "authEnv": "DASHSCOPE_API_KEY",
146
+ "consoleUrl": "https://bailian.console.aliyun.com/",
140
147
  "models": [
141
148
  {
142
149
  "id": "qwen3.5-plus",
@@ -179,6 +186,7 @@
179
186
  "baseUrl": "https://api.moonshot.cn/v1",
180
187
  "api": "openai-completions",
181
188
  "authEnv": "MOONSHOT_API_KEY",
189
+ "consoleUrl": "https://platform.moonshot.cn/console/api-keys",
182
190
  "models": [
183
191
  {
184
192
  "id": "kimi-k3",
@@ -220,6 +228,7 @@
220
228
  "baseUrl": "https://open.bigmodel.cn/api/paas/v4",
221
229
  "api": "openai-completions",
222
230
  "authEnv": "ZHIPU_API_KEY",
231
+ "consoleUrl": "https://open.bigmodel.cn/usercenter/apikeys",
223
232
  "models": [
224
233
  {
225
234
  "id": "glm-5.2",
@@ -267,6 +276,7 @@
267
276
  "baseUrl": "https://api.z.ai/api/paas/v4",
268
277
  "api": "openai-completions",
269
278
  "authEnv": "ZAI_API_KEY",
279
+ "consoleUrl": "https://z.ai/manage-apikey/apikey-list",
270
280
  "models": [
271
281
  {
272
282
  "id": "glm-5.2",
@@ -309,6 +319,7 @@
309
319
  "baseUrl": "https://api.minimax.io/anthropic",
310
320
  "api": "anthropic-messages",
311
321
  "authEnv": "MINIMAX_API_KEY",
322
+ "consoleUrl": "https://platform.minimax.io/",
312
323
  "models": [
313
324
  {
314
325
  "id": "MiniMax-M3",
@@ -335,6 +346,7 @@
335
346
  "baseUrl": "https://api.minimaxi.com/anthropic",
336
347
  "api": "anthropic-messages",
337
348
  "authEnv": "MINIMAX_CN_API_KEY",
349
+ "consoleUrl": "https://platform.minimaxi.com/",
338
350
  "models": [
339
351
  {
340
352
  "id": "MiniMax-M3",
@@ -355,6 +367,7 @@
355
367
  "baseUrl": "https://api.moonshot.ai/v1",
356
368
  "api": "openai-completions",
357
369
  "authEnv": "MOONSHOT_API_KEY",
370
+ "consoleUrl": "https://platform.moonshot.ai/console/api-keys",
358
371
  "models": [
359
372
  {
360
373
  "id": "kimi-k2.6",
@@ -401,6 +414,7 @@
401
414
  "baseUrl": "https://qianfan.baidubce.com/v2",
402
415
  "api": "openai-completions",
403
416
  "authEnv": "QIANFAN_API_KEY",
417
+ "consoleUrl": "https://console.bce.baidu.com/qianfan/",
404
418
  "models": [
405
419
  {
406
420
  "id": "ernie-5.0-thinking-preview",
@@ -421,6 +435,7 @@
421
435
  "baseUrl": "https://ark.cn-beijing.volces.com/api/v3",
422
436
  "api": "openai-completions",
423
437
  "authEnv": "VOLCANO_ENGINE_API_KEY",
438
+ "consoleUrl": "https://console.volcengine.com/ark",
424
439
  "models": [
425
440
  {
426
441
  "id": "doubao-seed-code-preview-251028",
@@ -504,6 +519,8 @@
504
519
  "baseUrl": "https://openrouter.ai/api/v1",
505
520
  "api": "openai-completions",
506
521
  "authEnv": "OPENROUTER_API_KEY",
522
+ "consoleUrl": "https://openrouter.ai/keys",
523
+ "signupUrl": "https://openrouter.ai/",
507
524
  "models": [
508
525
  {
509
526
  "id": "openrouter/auto",
@@ -525,6 +542,7 @@
525
542
  "baseUrl": "https://api.x.ai/v1",
526
543
  "api": "openai-completions",
527
544
  "authEnv": "XAI_API_KEY",
545
+ "consoleUrl": "https://console.x.ai/",
528
546
  "models": [
529
547
  {
530
548
  "id": "grok-4.3",
@@ -552,6 +570,7 @@
552
570
  "baseUrl": "https://api.mistral.ai/v1",
553
571
  "api": "openai-completions",
554
572
  "authEnv": "MISTRAL_API_KEY",
573
+ "consoleUrl": "https://console.mistral.ai/api-keys",
555
574
  "models": [
556
575
  {
557
576
  "id": "mistral-large-latest",
@@ -589,6 +608,7 @@
589
608
  "baseUrl": "https://api.groq.com/openai/v1",
590
609
  "api": "openai-completions",
591
610
  "authEnv": "GROQ_API_KEY",
611
+ "consoleUrl": "https://console.groq.com/keys",
592
612
  "models": [
593
613
  {
594
614
  "id": "groq/compound",
@@ -615,6 +635,7 @@
615
635
  "baseUrl": "https://api.together.xyz/v1",
616
636
  "api": "openai-completions",
617
637
  "authEnv": "TOGETHER_API_KEY",
638
+ "consoleUrl": "https://api.together.xyz/settings/api-keys",
618
639
  "models": [
619
640
  {
620
641
  "id": "moonshotai/Kimi-K2.6",
@@ -645,6 +666,7 @@
645
666
  "baseUrl": "https://api.fireworks.ai/inference/v1",
646
667
  "api": "openai-completions",
647
668
  "authEnv": "FIREWORKS_API_KEY",
669
+ "consoleUrl": "https://fireworks.ai/account/api-keys",
648
670
  "models": [
649
671
  {
650
672
  "id": "accounts/fireworks/models/kimi-k2p6",
@@ -691,6 +713,7 @@
691
713
  "baseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3",
692
714
  "api": "openai-completions",
693
715
  "authEnv": "BYTEPLUS_API_KEY",
716
+ "consoleUrl": "https://console.byteplus.com/",
694
717
  "models": [
695
718
  {
696
719
  "id": "seed-1-8-251228",
@@ -717,6 +740,7 @@
717
740
  "baseUrl": "https://api.cerebras.ai/v1",
718
741
  "api": "openai-completions",
719
742
  "authEnv": "CEREBRAS_API_KEY",
743
+ "consoleUrl": "https://cloud.cerebras.ai/",
720
744
  "models": [
721
745
  {
722
746
  "id": "zai-glm-4.7",
@@ -775,6 +799,7 @@
775
799
  "baseUrl": "https://api.cohere.ai/compatibility/v1",
776
800
  "api": "openai-completions",
777
801
  "authEnv": "COHERE_API_KEY",
802
+ "consoleUrl": "https://dashboard.cohere.com/api-keys",
778
803
  "models": [
779
804
  {
780
805
  "id": "command-a-03-2025",
@@ -790,6 +815,7 @@
790
815
  "baseUrl": "https://api.deepinfra.com/v1/openai",
791
816
  "api": "openai-completions",
792
817
  "authEnv": "DEEPINFRA_API_KEY",
818
+ "consoleUrl": "https://deepinfra.com/dash/api_keys",
793
819
  "models": [
794
820
  {
795
821
  "id": "deepseek-ai/DeepSeek-V4-Flash",
@@ -842,6 +868,8 @@
842
868
  "baseUrl": "https://router.huggingface.co/v1",
843
869
  "api": "openai-completions",
844
870
  "authEnv": "HF_TOKEN",
871
+ "consoleUrl": "https://huggingface.co/settings/tokens",
872
+ "signupUrl": "https://huggingface.co/join",
845
873
  "models": [
846
874
  {
847
875
  "id": "deepseek-ai/DeepSeek-V3.1",
@@ -947,6 +975,7 @@
947
975
  "baseUrl": "https://integrate.api.nvidia.com/v1",
948
976
  "api": "openai-completions",
949
977
  "authEnv": "NVIDIA_API_KEY",
978
+ "consoleUrl": "https://build.nvidia.com/",
950
979
  "models": [
951
980
  {
952
981
  "id": "nvidia/nemotron-3-ultra-550b-a55b",
@@ -1064,6 +1093,7 @@
1064
1093
  "baseUrl": "https://api.stepfun.com/v1",
1065
1094
  "api": "openai-completions",
1066
1095
  "authEnv": "STEPFUN_API_KEY",
1096
+ "consoleUrl": "https://platform.stepfun.com/",
1067
1097
  "models": [
1068
1098
  {
1069
1099
  "id": "step-3.5-flash",
@@ -1143,6 +1173,7 @@
1143
1173
  "baseUrl": "https://ai-gateway.vercel.sh",
1144
1174
  "api": "anthropic-messages",
1145
1175
  "authEnv": "AI_GATEWAY_API_KEY",
1176
+ "consoleUrl": "https://vercel.com/dashboard",
1146
1177
  "models": [
1147
1178
  {
1148
1179
  "id": "anthropic/claude-opus-4.6",
@@ -182,9 +182,11 @@ type BgTaskStatusProbe = (taskId: string) => Promise<{
182
182
  terminal: boolean;
183
183
  status: string;
184
184
  } | null>;
185
+ /** 委派 prompt 取件口(行消费端建行/详情页兜底用)。未登记 ⇒ undefined(诚实缺席)。 */
186
+ export declare function outstandingBgTaskPrompt(taskId: string): string | undefined;
185
187
  export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void;
186
188
  /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
187
- export declare function registerOutstandingBgTask(taskId: string, description: string): void;
189
+ export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string): void;
188
190
  export declare function isOwnWorkflowRun(runId: string): boolean;
189
191
  /** 本壳亲手启动过的 workflow run 列表(Set 插入序 = 启动序;/workflows 命令的目标 run 选择用,
190
192
  * cmd-workflows.tsx——fleet source 无行可选时的兜底 id 源)。 */
@@ -388,12 +388,31 @@ export function installWorkflowStatusProbe(probe) {
388
388
  }
389
389
  const outstandingBgTasks = new Map();
390
390
  let bgStatusProbe = null;
391
+ /**
392
+ * R4(clay 五报 07-31)委派 prompt 台账:`async_launched` 回执带全 {task_id, description, prompt}
393
+ * 三件,但此前 prompt 只进消息 payload(ctrl+o expand 那条腿),任务行/详情页(↓ manage)取不到,
394
+ * 只能绕道等引擎 running 期不发的 transcriptId 去转录读面捞 ⇒ Prompt 节恒空。
395
+ * 这里按行 id 记一份(与 fleet 行 rowIdTail 同 keyspace),行消费端建行时取用。
396
+ * 与 outstandingBgTasks 分开存:后者随通知摘除,prompt 在行的整个生命周期都要可取。
397
+ * process-lifetime 有界:bg 子代数量 = 人手派发量级,不设逐出。
398
+ */
399
+ const bgTaskPrompts = new Map();
400
+ /** 委派 prompt 取件口(行消费端建行/详情页兜底用)。未登记 ⇒ undefined(诚实缺席)。 */
401
+ export function outstandingBgTaskPrompt(taskId) {
402
+ return bgTaskPrompts.get(taskId);
403
+ }
391
404
  export function installBgTaskStatusProbe(probe) {
392
405
  bgStatusProbe = probe;
393
406
  }
394
407
  /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
395
- export function registerOutstandingBgTask(taskId, description) {
396
- if (!taskId || notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
408
+ export function registerOutstandingBgTask(taskId, description, prompt) {
409
+ if (!taskId)
410
+ return;
411
+ // prompt 台账先记(与 watcher 登记的幂等早退解耦:重复回执/已通知任务的 prompt 仍要可取)。
412
+ if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
413
+ bgTaskPrompts.set(taskId, prompt);
414
+ }
415
+ if (notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
397
416
  return;
398
417
  outstandingBgTasks.set(taskId, { registeredAt: Date.now(), description });
399
418
  ensureWatchTimer();
@@ -75,6 +75,13 @@ export const STRUCTURED_DETAIL_TYPES = new Set([
75
75
  'fork',
76
76
  'enter-plan-mode',
77
77
  'exit-plan-mode',
78
+ // core 2.7.0 跟车四型(engine-vocab 等值门抓获,[dep-bump-follow-on] 计数钉族):本表只判
79
+ // 「structured 在场」,switch 认不得的照旧走 text 回落 —— 加型=让正则反解对新卡退位,不加型
80
+ // =把引擎的结构化当裸对象。
81
+ 'file_unchanged',
82
+ 'worktree',
83
+ 'monitor-start',
84
+ 'path_not_in_root',
78
85
  ]);
79
86
  /** structured 在场判别:顶层 `type` ∈ 白名单 ⇒ 返回该 type,否则 undefined(= 不在场)。 */
80
87
  export function structuredDetailType(structured) {
@@ -610,7 +617,7 @@ modelText) {
610
617
  if (agentId === undefined || typeof s.description !== 'string' || typeof s.prompt !== 'string') {
611
618
  return null;
612
619
  }
613
- registerOutstandingBgTask(agentId, s.description);
620
+ registerOutstandingBgTask(agentId, s.description, s.prompt);
614
621
  return {
615
622
  toolUseResult: {
616
623
  isAsync: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.11.16",
3
+ "version": "0.11.18",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",