dsh-agy-acp 0.1.1 → 0.1.3

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
@@ -26,6 +26,7 @@
26
26
  | 本地精准单步 Token 账单 | 只读打开本机 `agy` SQLite 会话库——**无 WAL 侧车时**用 `?immutable=1`(不生成 `-wal`/`-shm`);**活跃 WAL 存在时**退回普通只读,避免读到未 checkpoint 的半截快照,并与 ACP `usage_update` 做容差对账 |
27
27
  | 传递图片附件或本地截图 | 支持传入授权本地路径、`data:image/*;base64,...` 或宿主附件引用;远端**明确声明** `promptCapabilities.image=false` 时降级为纯文本并告警,未声明(`unknown`)则先按支持尝试发送 |
28
28
  | 按需查看剩余额度 | 监控面板点击「刷新额度」才按需运行只读命令 `agy -p /usage --output-format json`(可选 `/credits`),打开面板不自动起进程 |
29
+ | 可视化管控模型权限 | 设置页「模型权限控制」逐模型切换 **允许 / 仅 Plan / 禁用**,直接写入 `blockedModels` / `planOnlyModels`;被禁用的模型仍留在列表里可随时改回,还能手输自定义 ID 封堵上游新模型。`agy_acp({ action: "models" })` 同步输出 `[blocked]` / `[plan-only]` 标注 |
29
30
 
30
31
  ## 快速开始
31
32
 
@@ -74,6 +75,7 @@ AgyWorkerService (src/worker-service.js)
74
75
  | **后台 `wait:false` 拒绝非白名单操作** | 父会话处于轮次间 idle 时,不在 `bridgeAllowlist` 内的权限请求一律 `fail-closed` 拒绝;仅 `wait:true` 前台模式弹窗询问宿主审批。 |
75
76
  | **Windows Low Integrity 保护** | 若 `agy` 落在 `AppData\\LocalLow` 或 ACL 含 `Low Mandatory Level`(`S-1-16-4096`),插件会直接拒绝启动并报错(`AGY_LOW_INTEGRITY_EXECUTABLE`),防止子进程挂死。 |
76
77
  | **额度与账单可信度** | 本地 SQLite 账单与 `/usage`/`/credits` 均零联网修改且严禁把 `unknown` 伪装成 `0`,仅作本地监控参考,不作为正式计费依据。 |
78
+ | **模型权限是本地策略** | `blockedModels` / `planOnlyModels` 在工具层与 `runDirectTurn` 双路径按归一化模型名(大小写不敏感)拦截,但无法阻止其他客户端直连 `agy`。零思考档位的 `Orchestrator (Carbon)` 与 `gemini-pro-orchestrator-argon` 拒绝任何思考档位。 |
77
79
 
78
80
  ## 常见排障
79
81
 
package/client.js CHANGED
@@ -45,6 +45,9 @@
45
45
  requestTimeoutMs: 600000,
46
46
  maxReportChars: 32000,
47
47
  quotaIncludeCredits: false,
48
+ // 模型权限黑白名单:恢复默认时一并清空(空数组 = 不限制任何模型)。
49
+ blockedModels: Object.freeze([]),
50
+ planOnlyModels: Object.freeze([]),
48
51
  });
49
52
  const MODEL_PRESETS = Object.freeze([
50
53
  'gemini-4-carbon',
@@ -53,6 +56,27 @@
53
56
  'gemini-pro-orchestrator',
54
57
  'claude-opus-5-5',
55
58
  ]);
59
+ /**
60
+ * 模型权限卡片的内置静态目录(与 Host DEFAULT_MODELS 对齐,并补上 Argon Orchestrator)。
61
+ * 即使连接未就绪、拿不到 snapshot.models,这里也足以渲染三态按钮。
62
+ */
63
+ const DEFAULT_MODEL_OPTIONS = Object.freeze([
64
+ 'gemini-4-carbon',
65
+ 'gemini-4-hydrogen',
66
+ 'gemini-pro-orchestrator',
67
+ 'claude-opus-5-5',
68
+ 'gemini-4-argon',
69
+ 'gemini-pro-orchestrator-argon',
70
+ 'claude-sonnet-5-5',
71
+ 'gpt-oss-120b-medium',
72
+ ]);
73
+ /** 三态权限取值与对应文案 key(顺序即按钮顺序)。 */
74
+ const MODEL_ACCESS_VALUES = Object.freeze(['allow', 'plan-only', 'blocked']);
75
+ const MODEL_ACCESS_LABEL_KEYS = Object.freeze({
76
+ allow: 'accessAllow',
77
+ 'plan-only': 'accessPlanOnly',
78
+ blocked: 'accessBlocked',
79
+ });
56
80
  const EFFORT_PRESETS = Object.freeze(['low', 'medium', 'high', 'xhigh']);
57
81
  const CONCURRENCY_PRESETS = Object.freeze([1, 2, 3, 4]);
58
82
  const HANDSHAKE_TIMEOUT_PRESETS = Object.freeze([30000, 60000, 120000, 300000]);
@@ -77,6 +101,22 @@
77
101
  return value !== null && typeof value === 'object' && !Array.isArray(value);
78
102
  }
79
103
 
104
+ /**
105
+ * 解包真实宿主 @deepseek-ai/dsh-api-gateway 的一元 Remote 结果信封。
106
+ *
107
+ * 网关客户端 invoke() 的成功返回是 { ok: true, value: <payload> }(失败是
108
+ * { ok: false, error }),并不是裸 payload;离线的远程替身与旧宿主则直接返回
109
+ * payload。这里只剥一层、且只剥「成功信封」:
110
+ * - ok:false 的失败信封原样返回,交给调用方既有的错误分支处理;
111
+ * - 裸 payload 原样返回,绝不误剥业务字段里自带的 ok/value 组合。
112
+ */
113
+ function unwrapRemoteResult(response) {
114
+ if (isPlainObject(response) && response.ok === true && 'value' in response && response.value !== undefined) {
115
+ return response.value;
116
+ }
117
+ return response;
118
+ }
119
+
80
120
  function resolveService(ctx, name) {
81
121
  if (!ctx || typeof ctx !== 'object') return null;
82
122
  try {
@@ -354,6 +394,100 @@
354
394
  };
355
395
  }
356
396
 
397
+ /** 模型目录项:`{ value, name }`,空 value 丢弃(与 Host src/protocol.js 1:1 同步)。 */
398
+ function normalizeModelOption(raw) {
399
+ const value = toStr(raw && raw.value, '');
400
+ if (!value) return null;
401
+ const name = toStr(raw && raw.name, '');
402
+ return { value, name: name || value };
403
+ }
404
+
405
+ function normalizeModelCatalog(raw) {
406
+ if (!Array.isArray(raw)) return [];
407
+ const out = [];
408
+ for (const item of raw) {
409
+ const option = normalizeModelOption(item);
410
+ if (option) out.push(option);
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /* ---------------- 模型权限(允许 / 仅 Plan / 禁用)辅助 ---------------- */
416
+
417
+ /** 归一化字符串列表:去空、去重(保留原始大小写与首次出现顺序)。 */
418
+ function normalizeStringList(value) {
419
+ if (!Array.isArray(value)) return [];
420
+ const out = [];
421
+ const seen = new Set();
422
+ for (const item of value) {
423
+ if (typeof item !== 'string') continue;
424
+ const text = item.trim();
425
+ if (!text) continue;
426
+ const key = text.toLowerCase();
427
+ if (seen.has(key)) continue;
428
+ seen.add(key);
429
+ out.push(text);
430
+ }
431
+ return out;
432
+ }
433
+
434
+ /**
435
+ * 合并模型目录:接受 string[] 或 { value, name }[] 两种来源形态,
436
+ * 按大小写不敏感去重并保留首次出现顺序。
437
+ */
438
+ function mergeModelCatalog(...sources) {
439
+ const seen = new Set();
440
+ const out = [];
441
+ const push = (value, name) => {
442
+ const text = typeof value === 'string' ? value.trim() : '';
443
+ if (!text) return;
444
+ const key = text.toLowerCase();
445
+ if (seen.has(key)) return;
446
+ seen.add(key);
447
+ out.push({ value: text, name: typeof name === 'string' && name ? name : text });
448
+ };
449
+ for (const source of sources) {
450
+ if (!Array.isArray(source)) continue;
451
+ for (const item of source) {
452
+ if (typeof item === 'string') push(item, item);
453
+ else if (item) push(item.value, item.name);
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /** 某模型当前的三态权限:allow | plan-only | blocked(大小写不敏感)。 */
460
+ function modelAccessOf(value, blockedModels, planOnlyModels) {
461
+ const key = typeof value === 'string' ? value.trim().toLowerCase() : '';
462
+ if (!key) return 'allow';
463
+ const matches = (list) =>
464
+ normalizeStringList(list).some((item) => item.toLowerCase() === key);
465
+ if (matches(blockedModels)) return 'blocked';
466
+ return matches(planOnlyModels) ? 'plan-only' : 'allow';
467
+ }
468
+
469
+ /**
470
+ * 计算点三态按钮后的新黑白名单(纯函数,便于单测):
471
+ * - allow:从两个数组里都移除;
472
+ * - plan-only:加入 planOnlyModels、从 blockedModels 移除;
473
+ * - blocked:加入 blockedModels、从 planOnlyModels 移除。
474
+ */
475
+ function nextModelPolicy(blockedModels, planOnlyModels, value, access) {
476
+ const target = typeof value === 'string' ? value.trim() : '';
477
+ if (!target) {
478
+ return { blockedModels: normalizeStringList(blockedModels), planOnlyModels: normalizeStringList(planOnlyModels) };
479
+ }
480
+ const key = target.toLowerCase();
481
+ const blockedNow = normalizeStringList(blockedModels);
482
+ const planOnlyNow = normalizeStringList(planOnlyModels);
483
+ const withoutKey = (list) => list.filter((item) => item.toLowerCase() !== key);
484
+ const withKey = (list) => (list.some((item) => item.toLowerCase() === key) ? list : [...list, target]);
485
+ return {
486
+ blockedModels: access === 'blocked' ? withKey(blockedNow) : withoutKey(blockedNow),
487
+ planOnlyModels: access === 'plan-only' ? withKey(planOnlyNow) : withoutKey(planOnlyNow),
488
+ };
489
+ }
490
+
357
491
  function normalizeSnapshot(raw) {
358
492
  const semaphore = isPlainObject(raw && raw.semaphore) ? raw.semaphore : {};
359
493
  const totals = isPlainObject(raw && raw.totals) ? raw.totals : {};
@@ -383,6 +517,7 @@
383
517
  activeRuns: Array.isArray(raw && raw.activeRuns) ? raw.activeRuns.map(normalizeRun) : [],
384
518
  recentRuns: Array.isArray(raw && raw.recentRuns) ? raw.recentRuns.map(normalizeRun) : [],
385
519
  workers: Array.isArray(raw && raw.workers) ? raw.workers.map(normalizeWorker) : [],
520
+ models: normalizeModelCatalog(raw && raw.models),
386
521
  quota: raw && raw.quota ? normalizeQuota(raw.quota) : null,
387
522
  };
388
523
  }
@@ -684,6 +819,15 @@
684
819
  reportLabel: '报告字符上限',
685
820
  reportHint: '后台 Job 报告中回复正文的最大字符数,超出会截断。',
686
821
  resetDefaults: '恢复默认设置',
822
+ modelAccessTitle: '模型权限控制(允许 / 仅 Plan / 禁用)',
823
+ modelAccessHint: '逐个模型设置权限:允许 = 照常使用;仅 Plan = 只有 mode:"plan" 下可用;禁用 = 任何调用都会被拒绝。被标记的模型仍会留在这张列表里,随时可以改回来。',
824
+ modelAccessLabel: '权限',
825
+ accessAllow: '允许',
826
+ accessPlanOnly: '仅 Plan',
827
+ accessBlocked: '禁用',
828
+ customModelLabel: '手动添加模型 ID',
829
+ customModelPlaceholder: '例如 gemini-4-argon',
830
+ customModelHint: '上游新出的模型 ID 可直接在这里封堵:输入后点「仅 Plan」或「禁用」,会写入 planOnlyModels / blockedModels(大小写不敏感)。',
687
831
  guideTitle: '监控与使用说明',
688
832
  guideMonitor: '右侧栏会新增「ACP 进程监控」页签:活跃/排队轮次、最近 30 轮单步 SQLite Token 账单与 ACP usage_update 对账条都在那里。也可以点击会话顶栏的 ACP 徽章直接打开。',
689
833
  guideBilling: '账单数据全部来自本机 agy 的 SQLite 会话库(只读、immutable 打开,不会生成 -wal/-shm 侧车文件),不需要联网,也不会额外消耗额度。',
@@ -727,6 +871,15 @@
727
871
  reportLabel: 'Report character cap',
728
872
  reportHint: 'Maximum reply characters kept in the background job report.',
729
873
  resetDefaults: 'Reset Defaults',
874
+ modelAccessTitle: 'Model access control (allow / plan-only / blocked)',
875
+ modelAccessHint: 'Set access per model: Allow = normal use; Plan only = usable only with mode:"plan"; Blocked = every call is rejected. Tagged models stay in this list so you can always switch them back.',
876
+ modelAccessLabel: 'Access',
877
+ accessAllow: 'Allow',
878
+ accessPlanOnly: 'Plan only',
879
+ accessBlocked: 'Blocked',
880
+ customModelLabel: 'Add a model ID manually',
881
+ customModelPlaceholder: 'e.g. gemini-4-argon',
882
+ customModelHint: 'Block upstream model IDs as soon as they appear: type one and press "Plan only" or "Blocked" to write planOnlyModels / blockedModels (case-insensitive).',
730
883
  guideTitle: 'Monitor and usage notes',
731
884
  guideMonitor: 'A new "ACP process monitor" tab appears in the right sidebar: active/queued runs, the last 30 turn ledgers and the SQLite-vs-ACP reconciliation bar. The header ACP badge opens it too.',
732
885
  guideBilling: 'Ledger data comes from the local agy SQLite conversation database, opened read-only with immutable=1 so no -wal/-shm sidecar files are created. Nothing leaves the machine.',
@@ -823,7 +976,7 @@
823
976
  if (!api || typeof api.getAcpSnapshot !== 'function') {
824
977
  throw new Error('Host 端 dsh-agy-acp.getAcpSnapshot 服务未就绪');
825
978
  }
826
- const response = await api.getAcpSnapshot({ sessionId: focusSessionId || GLOBAL });
979
+ const response = unwrapRemoteResult(await api.getAcpSnapshot({ sessionId: focusSessionId || GLOBAL }));
827
980
  if (isPlainObject(response) && response.ok === false) {
828
981
  throw new Error(formatErrorMessage(response.error || response));
829
982
  }
@@ -837,7 +990,7 @@
837
990
  if (!api || typeof api.stopAcpRun !== 'function') {
838
991
  return { ok: false, stopped: false, runId, phase: '', reason: 'host-unavailable' };
839
992
  }
840
- const response = await api.stopAcpRun({ runId });
993
+ const response = unwrapRemoteResult(await api.stopAcpRun({ runId }));
841
994
  if (isPlainObject(response) && response.ok === false && response.error) {
842
995
  throw new Error(formatErrorMessage(response.error));
843
996
  }
@@ -871,7 +1024,9 @@
871
1024
  if (!remoteApi || typeof remoteApi.getAcpQuota !== 'function') {
872
1025
  throw new Error('Host 端 dsh-agy-acp.getAcpQuota 服务未就绪');
873
1026
  }
874
- const response = await remoteApi.getAcpQuota(isPlainObject(options) ? options : { force: true });
1027
+ const response = unwrapRemoteResult(
1028
+ await remoteApi.getAcpQuota(isPlainObject(options) ? options : { force: true })
1029
+ );
875
1030
  if (isPlainObject(response) && response.ok === false && response.error) {
876
1031
  throw new Error(formatErrorMessage(response.error));
877
1032
  }
@@ -1050,7 +1205,10 @@
1050
1205
  order: PATCH_ORDER,
1051
1206
  label: () => (locale && typeof locale.bind === 'function' ? locale.bind(SETTINGS_NS)('title') : 'Antigravity ACP'),
1052
1207
  locale: SETTINGS_NS,
1053
- inject: () => ({ form }),
1208
+ // api 是上面构造好的客户端封装(fetchSnapshot 已解包宿主网关信封),
1209
+ // 传入后设置页能真正拉到宿主实测 snapshot.models;拿不到时组件自动降级到
1210
+ // 内置静态目录,绝不阻塞渲染、也绝不让离线单测崩掉。
1211
+ inject: () => ({ form, api }),
1054
1212
  }, AcpSettingsView))
1055
1213
  ));
1056
1214
  return () => {
@@ -1168,6 +1326,14 @@
1168
1326
  ACP_REMOTE,
1169
1327
  SETTINGS_DICTIONARIES,
1170
1328
  DEFAULT_SETTINGS,
1329
+ DEFAULT_MODEL_OPTIONS,
1330
+ MODEL_ACCESS_VALUES,
1331
+ unwrapRemoteResult,
1332
+ normalizeModelCatalog,
1333
+ normalizeStringList,
1334
+ mergeModelCatalog,
1335
+ modelAccessOf,
1336
+ nextModelPolicy,
1171
1337
  initRuntime,
1172
1338
  buildAcpAddress,
1173
1339
  parseAcpAddress,
@@ -1240,6 +1406,13 @@
1240
1406
  const [pending, setPending] = React.useState(false);
1241
1407
  const [notice, setNotice] = React.useState('');
1242
1408
  const [drafts, setDrafts] = React.useState({});
1409
+ // 模型权限卡片的数据源:props.models(宿主/测试注入)+ 连接就绪后异步拉一次快照。
1410
+ const [catalogModels, setCatalogModels] = React.useState(() =>
1411
+ props && Array.isArray(props.models) ? props.models : []
1412
+ );
1413
+ const [customModelDraft, setCustomModelDraft] = React.useState('');
1414
+ // 输入框最新值同时落在 ref:点击「添加」时即便宿主还没重渲染也能读到用户刚敲的 ID。
1415
+ const customModelRef = React.useRef('');
1243
1416
  const writing = React.useRef(false);
1244
1417
  const alive = React.useRef(true);
1245
1418
  const draftRef = React.useRef({});
@@ -1251,6 +1424,35 @@
1251
1424
  };
1252
1425
  }, []);
1253
1426
 
1427
+ // 连接就绪后再补一次真实模型目录;任何异常(远程未挂载 / 未注入 api)都静默
1428
+ // 降级到内置静态目录,绝不阻塞渲染、也绝不让离线单测崩掉。
1429
+ // 两种注入形态都支持:本插件客户端封装 api.fetchSnapshot(已解包宿主网关
1430
+ // { ok: true, value } 信封),以及宿主远程命名空间 api.getAcpSnapshot(这里再解一次)。
1431
+ const snapshotApi = props && props.api ? props.api : null;
1432
+ const useApiFetch = Boolean(snapshotApi && typeof snapshotApi.fetchSnapshot === 'function');
1433
+ const canPullSnapshot =
1434
+ useApiFetch || Boolean(snapshotApi && typeof snapshotApi.getAcpSnapshot === 'function');
1435
+ React.useEffect(() => {
1436
+ if (!canPullSnapshot || !snapshotApi) return undefined;
1437
+ let cancelled = false;
1438
+ const pull = async () => {
1439
+ try {
1440
+ const snap = useApiFetch
1441
+ ? await snapshotApi.fetchSnapshot(GLOBAL)
1442
+ : unwrapRemoteResult(await snapshotApi.getAcpSnapshot({ sessionId: GLOBAL }));
1443
+ if (cancelled) return;
1444
+ const models = normalizeModelCatalog(snap && snap.models);
1445
+ if (models.length > 0) setCatalogModels(models);
1446
+ } catch {
1447
+ /* 忽略:静态目录已经够用 */
1448
+ }
1449
+ };
1450
+ void pull();
1451
+ return () => {
1452
+ cancelled = true;
1453
+ };
1454
+ }, [canPullSnapshot, snapshotApi, useApiFetch]);
1455
+
1254
1456
  const val = (state && state.value) || {};
1255
1457
  const isReady = state && state.status === 'ready';
1256
1458
  const isReadonly = isReady && state.writable !== true;
@@ -1330,6 +1532,60 @@
1330
1532
  }
1331
1533
  };
1332
1534
 
1535
+ const blockedModels = normalizeStringList(readValue('blockedModels'));
1536
+ const planOnlyModels = normalizeStringList(readValue('planOnlyModels'));
1537
+ // 静态目录(稳定顺序)→ 真实快照 → 当前配置里出现过的自定义 ID,全部按大小写不敏感去重。
1538
+ const modelRows = mergeModelCatalog(DEFAULT_MODEL_OPTIONS, catalogModels, blockedModels, planOnlyModels);
1539
+ const accessLabel = (access) => t(MODEL_ACCESS_LABEL_KEYS[access] || 'accessAllow');
1540
+
1541
+ /**
1542
+ * 三态切换:两个数组必须顺序写入(先 blockedModels、后 planOnlyModels),
1543
+ * 全程受同一把 writing.current 防重入锁保护,只读态直接不写。
1544
+ */
1545
+ const applyModelAccess = async (modelValue, access) => {
1546
+ if (!alive.current || disabled || writing.current || !formWritable()) return false;
1547
+ const target = typeof modelValue === 'string' ? modelValue.trim() : '';
1548
+ if (!target || !MODEL_ACCESS_VALUES.includes(access)) return false;
1549
+ const next = nextModelPolicy(
1550
+ readValue('blockedModels'),
1551
+ readValue('planOnlyModels'),
1552
+ target,
1553
+ access
1554
+ );
1555
+ writing.current = true;
1556
+ setPending(true);
1557
+ setNotice('');
1558
+ try {
1559
+ const blockedOk = Boolean(await form.set('blockedModels', next.blockedModels));
1560
+ let ok = blockedOk;
1561
+ if (blockedOk) ok = Boolean(await form.set('planOnlyModels', next.planOnlyModels));
1562
+ if (alive.current) setNotice(ok ? 'saved' : 'error');
1563
+ return ok;
1564
+ } catch {
1565
+ if (alive.current) setNotice('error');
1566
+ return false;
1567
+ } finally {
1568
+ writing.current = false;
1569
+ if (alive.current) setPending(false);
1570
+ }
1571
+ };
1572
+
1573
+ const editCustomModel = (value) => {
1574
+ const text = typeof value === 'string' ? value : '';
1575
+ customModelRef.current = text;
1576
+ if (alive.current) setCustomModelDraft(text);
1577
+ };
1578
+
1579
+ const addCustomModel = async (access) => {
1580
+ const target = String(customModelRef.current || '').trim();
1581
+ if (!target || !MODEL_ACCESS_VALUES.includes(access)) return;
1582
+ const ok = await applyModelAccess(target, access);
1583
+ if (ok && alive.current) {
1584
+ customModelRef.current = '';
1585
+ setCustomModelDraft('');
1586
+ }
1587
+ };
1588
+
1333
1589
  const presetGroup = (labelKey, hintKey, field, values, formatter) =>
1334
1590
  h('div', { className: 'row' },
1335
1591
  h('strong', null, t(labelKey)),
@@ -1367,6 +1623,67 @@
1367
1623
  h('p', { className: 'muted' }, t('effortHint'))
1368
1624
  ),
1369
1625
 
1626
+ h('div', { className: 'card', 'data-card': 'model-access' },
1627
+ h('h3', null, t('modelAccessTitle')),
1628
+ h('div', { className: 'model-access-list', role: 'list', 'aria-label': t('modelAccessTitle') },
1629
+ ...modelRows.map((model) => {
1630
+ const access = modelAccessOf(model.value, blockedModels, planOnlyModels);
1631
+ return h('div', { key: model.value, className: 'model-access-row', role: 'listitem' },
1632
+ h('span', { className: 'model-access-name' },
1633
+ h('strong', null, model.value),
1634
+ model.name && model.name !== model.value
1635
+ ? h('span', { className: 'muted model-access-label' }, '(' + model.name + ')')
1636
+ : null
1637
+ ),
1638
+ h('div', {
1639
+ className: 'group',
1640
+ role: 'group',
1641
+ 'aria-label': model.value + ' · ' + t('modelAccessLabel'),
1642
+ },
1643
+ ...MODEL_ACCESS_VALUES.map((value) => h('button', {
1644
+ key: value,
1645
+ type: 'button',
1646
+ 'aria-pressed': access === value,
1647
+ 'data-model-access': value,
1648
+ 'data-model': model.value,
1649
+ disabled,
1650
+ onClick: () => applyModelAccess(model.value, value),
1651
+ }, accessLabel(value)))
1652
+ )
1653
+ );
1654
+ })
1655
+ ),
1656
+ h('p', { className: 'muted' }, t('modelAccessHint')),
1657
+ h('div', { className: 'field' },
1658
+ h('label', { htmlFor: 'agy-acp-custom-model' }, t('customModelLabel')),
1659
+ h('input', {
1660
+ id: 'agy-acp-custom-model',
1661
+ name: 'customModel',
1662
+ type: 'text',
1663
+ autoComplete: 'off',
1664
+ spellCheck: false,
1665
+ disabled,
1666
+ 'data-field': 'customModel',
1667
+ placeholder: t('customModelPlaceholder'),
1668
+ value: customModelDraft,
1669
+ onChange: (event) => editCustomModel(event.target.value),
1670
+ }),
1671
+ h('button', {
1672
+ type: 'button',
1673
+ 'data-action': 'add-model-plan-only',
1674
+ disabled: disabled || !String(customModelDraft || '').trim(),
1675
+ onClick: () => addCustomModel('plan-only'),
1676
+ }, t('accessPlanOnly')),
1677
+ h('button', {
1678
+ type: 'button',
1679
+ 'data-action': 'add-model-blocked',
1680
+ disabled: disabled || !String(customModelDraft || '').trim(),
1681
+ onClick: () => addCustomModel('blocked'),
1682
+ }, t('accessBlocked'))
1683
+ ),
1684
+ h('p', { className: 'muted' }, t('customModelHint'))
1685
+ ),
1686
+
1370
1687
  h('div', { className: 'card', 'data-card': 'runtime' },
1371
1688
  h('h3', null, t('runtimeTitle')),
1372
1689
  presetGroup('concurrencyLabel', 'concurrencyHint', 'maxConcurrent', CONCURRENCY_PRESETS),
@@ -1565,6 +1882,14 @@
1565
1882
  '.dsh-agy-acp-settings input{font:inherit;width:100%;padding:7px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
1566
1883
  '.dsh-agy-acp-settings .status{min-height:20px;font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:4px}',
1567
1884
  '.dsh-agy-acp-settings .status.error{color:var(--dsw-alias-state-error-primary)}',
1885
+ '.dsh-agy-acp-settings .model-access-list{display:flex;flex-direction:column;gap:6px;margin:8px 0}',
1886
+ '.dsh-agy-acp-settings .model-access-row{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1)}',
1887
+ '.dsh-agy-acp-settings .model-access-name{display:inline-flex;gap:6px;align-items:baseline;min-width:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;overflow-wrap:anywhere}',
1888
+ '.dsh-agy-acp-settings .model-access-label{font-family:inherit}',
1889
+ '.dsh-agy-acp-settings .model-access-row .group button{min-width:72px;padding:4px 10px}',
1890
+ '.dsh-agy-acp-settings .model-access-row .group button[aria-pressed="true"][data-model-access="blocked"]{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
1891
+ '.dsh-agy-acp-settings .model-access-row .group button[aria-pressed="true"][data-model-access="plan-only"]{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}',
1892
+ '.dsh-agy-acp-settings .model-access-row .group button[aria-pressed="true"][data-model-access="allow"]{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}',
1568
1893
  '.dsh-agy-acp-settings .guide summary{cursor:pointer;font-weight:600;font-size:14px;padding:2px 0;user-select:none}',
1569
1894
  ].join('\n');
1570
1895
 
package/docs/changelog.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  [返回主页](../README.md) · [配置参考](configuration.md) · [接口参考](reference.md) · [开发与发布](development.md)
4
4
 
5
+ ## [0.1.3] - 2026-11-15
6
+
7
+ ### 修复
8
+ - 修复真实宿主 `@deepseek-ai/dsh-api-gateway` 的一元 Remote 结果信封 `{ ok: true, value: <payload> }` 未解包的问题:`client.js` 新增 `unwrapRemoteResult()`(只剥成功信封;`ok: false` 失败信封与离线替身/旧宿主的裸 payload 一律原样返回),并接入 `fetchSnapshotOnce` / `stopRun` / `fetchQuotaOnce` 三处。
9
+ - 症状:面板快照恒为空、停止运行恒报 `stopped: false`、额度卡片读不到 `groups`,设置页也拿不到宿主实测模型列表。
10
+ - 设置页 `settings.section` 槽位的 `inject` 改为传入已构造好的客户端封装 `api`(原为远程命名空间),并把 `AcpSettingsView` 的快照拉取改走 `api.fetchSnapshot`(仍兼容 `api.getAcpSnapshot` 形态并同样解包信封),真实桌面打开设置页即可把宿主实测模型合并进权限矩阵;离线测试只传 `{ form }` 时仍安全降级、绝不报错。
11
+
12
+ ### 测试
13
+ - `test/acp-panel.test.mjs`:新增 `unwrapRemoteResult` 边界用例(成功信封 / 失败信封 / 裸 payload / `value: undefined`)、三处远程调用解包回归(`models`、`stopped: true`、`quota.groups`)、以及挂载后设置页注入 `api` 解包拉取 `models` 并合并进权限矩阵的回归。
14
+ - `test/acp-quota.test.mjs`:新增 `api.fetchQuota` 解包网关信封后额度卡片读到 `groups`(0 余额精确保留)与失败信封继续抛错的回归。
15
+
16
+ ## [0.1.2] - 2026-11-15
17
+
18
+ ### 新增
19
+ - 设置页新增「模型权限控制(允许 / 仅 Plan / 禁用)」卡片:逐模型三态切换,直接改写 `blockedModels` / `planOnlyModels`;两个数组顺序写入并共用同一把防重入锁,只读连接下全部禁用。
20
+ - 支持手动输入自定义模型 ID 并标记为「仅 Plan」或「禁用」,便于第一时间封堵上游新出现的模型。
21
+ - `getAcpSnapshot()` 新增 `models: [{ value, name }]` 字段(来自零进程的 `peekModels()`),`protocol.js` 与 `client.js` 内嵌描述器 1:1 同步归一化,旧快照缺字段时默认 `[]`。
22
+ - `agy_acp({ action: "models" })` 输出为每个模型标注 `[blocked]` / `[plan-only]` 访问状态并给出策略统计,同时保留完整模型目录。
23
+
24
+ ### 变更
25
+ - `DEFAULT_MODELS` 中 `gemini-pro-orchestrator` 的显示名更新为 `Orchestrator (Carbon)`。
26
+ - `MODEL_THOUGHT_LEVELS` 补充 `gemini-pro-orchestrator-argon` 为零思考档位(与 `gemini-pro-orchestrator` 一致),显式 `-low/-medium/-high/-xhigh` 一律拒绝。
27
+ - 设置页「恢复默认设置」现在会一并把 `blockedModels` 与 `planOnlyModels` 重置为空数组。
28
+
29
+ ### 测试
30
+ - 新增 Orchestrator (Carbon/Argon) 零档位锁死回归、模型权限三态切换、手动添加自定义模型、恢复默认清空黑白名单、只读态禁用、`snapshot.models` 协议归一化与热更新实时生效等用例。
31
+
5
32
  ## [0.1.1] - 2026-11-14
6
33
 
7
34
  ### 修复
@@ -24,8 +24,8 @@
24
24
  | `extraDirs` | `string[]` | `[]` | 启动 `agy` 时追加 `--add-dir`,同时计入本地图片读取的授权根目录 |
25
25
  | `mcpServers` | `object[]` | `[]` | 透传给 `session/new` 与 `session/load` 的 MCP 服务器列表 |
26
26
  | `bridgeAllowlist` | `object \| null` | `null` | MCP 桥权限白名单:`{ serverName, readOnlyTools, coordinationTools, writeTools }` |
27
- | `blockedModels` | `string[]` | `[]` | 一律拒绝的模型名(精确匹配归一化后的模型 ID,大小写不敏感) |
28
- | `planOnlyModels` | `string[]` | `[]` | 只允许在 `mode: "plan"` 下使用的模型名 |
27
+ | `blockedModels` | `string[]` | `[]` | 一律拒绝的模型名(精确匹配归一化后的模型 ID,大小写不敏感;设置页可视化三态编辑) |
28
+ | `planOnlyModels` | `string[]` | `[]` | 只允许在 `mode: "plan"` 下使用的模型名(同样支持设置页三态编辑) |
29
29
  | `quotaIncludeCredits` | `boolean` | `false` | 在设置页持久化:点击刷新额度时是否默认同时查询 `/credits` |
30
30
 
31
31
  ## 2. 可执行文件探测链与环境变量
@@ -69,3 +69,22 @@ config:
69
69
  - `readOnlyTools`:始终自动放行。
70
70
  - `coordinationTools`:仅产生受控协作副作用的工具,在 `plan` / 只读父会话下同样放行。
71
71
  - `writeTools`:仅非受限模式放行,`plan` / 只读模式下拒绝。
72
+
73
+ ## 4. 模型权限控制(0.1.2)
74
+
75
+ 设置页「默认模型与思考档位」之后新增**模型权限控制(允许 / 仅 Plan / 禁用)**卡片,与 `blockedModels` / `planOnlyModels` 双向对应:
76
+
77
+ | 点击 | 写入效果 |
78
+ | --- | --- |
79
+ | 允许 | 从 `blockedModels` 与 `planOnlyModels` 中同时移除该模型 |
80
+ | 仅 Plan | 加入 `planOnlyModels`,并从 `blockedModels` 移除 |
81
+ | 禁用 | 加入 `blockedModels`,并从 `planOnlyModels` 移除 |
82
+
83
+ - 两个数组按「先 `blockedModels`、后 `planOnlyModels`」顺序写入,全程共用同一把防重入锁;只读连接(`writable !== true`)下全部按钮禁用。
84
+ - 列表数据来源 = 内置静态目录(含 `gemini-pro-orchestrator-argon`)+ `snapshot.models`(连接就绪后异步拉取一次,零子进程)+ 当前配置里已有的自定义 ID,按大小写不敏感去重。
85
+ - 列表**不会隐藏**被禁用 / 仅 Plan 的模型,取消即可恢复;「恢复默认设置」会把两个数组一并清空。
86
+ - 手动添加输入框可直接封堵上游新模型 ID,并选择写入「仅 Plan」还是「禁用」。
87
+
88
+ ### 零思考档位模型
89
+
90
+ `gemini-pro-orchestrator`(显示名 `Orchestrator (Carbon)`)与 `gemini-pro-orchestrator-argon`(`Orchestrator (Argon)`)在服务端不提供 `thought_level`:静态表 `MODEL_THOUGHT_LEVELS` 对其锁定为空数组,任何显式 `effort` 或 `-low/-medium/-high/-xhigh` 后缀都会当场抛 `ACP_INVALID_CONFIG`,运行期也绝不下发 `set_config_option(thought_level)`。
package/docs/reference.md CHANGED
@@ -25,8 +25,9 @@
25
25
  | --- | --- | --- |
26
26
  | `carbon` / `gemini-4-carbon-high` | `gemini-4-carbon` (+ `high`) | 支持 `low` / `medium` / `high` |
27
27
  | `hydrogen` / `hydrogen-xhigh` / `hydrogen-max` | `gemini-4-hydrogen` + `xhigh` | 唯一支持 `xhigh` 的模型,拒绝 `minimal` |
28
- | `orchestrator` | `gemini-pro-orchestrator` | 服务端无 `thought_level`,传任何档位均报错 |
29
- | `opus` / `argon` / `sonnet` | `claude-opus-5-5` / `gemini-4-argon` / `claude-sonnet-5-5` | 可配合 `Config.blockedModels` / `planOnlyModels` 管控 |
28
+ | `orchestrator` | `gemini-pro-orchestrator`(显示名 `Orchestrator (Carbon)`) | 服务端无 `thought_level`,传任何档位(含 `-low/-medium/-high/-xhigh`)均报错 |
29
+ | `gemini-pro-orchestrator-argon` | `gemini-pro-orchestrator-argon`(显示名 `Orchestrator (Argon)`) | 同为官方零思考档位,静态表锁定为空,任何档位均拒绝;可配合 `Config.blockedModels` 封堵 |
30
+ | `opus` / `argon` / `sonnet` | `claude-opus-5-5` / `gemini-4-argon` / `claude-sonnet-5-5` | 可配合 `Config.blockedModels` / `planOnlyModels` 管控(模型权限卡片可视化设置) |
30
31
  | `gpt-oss-120b-medium` | `gpt-oss-120b-medium` (锁 `medium`) | 显式传冲突档位直接报错 |
31
32
 
32
33
  ## 3. 多模态图片输入规范
@@ -62,7 +63,9 @@
62
63
 
63
64
  - `service.runDirectTurn(input)`:绕过 `ctx.jobs` 直接排队执行单轮 ACP 任务。
64
65
  - `service.listModels({ cwd, accessMode, signal })`:握手探测真实目录(5 分钟缓存 + single-flight)。
65
- - `service.peekModels()`:同步零进程返回缓存或静态兜底目录。
66
+ - `service.peekModels()`:同步零进程返回缓存或静态兜底目录(**保留全部已知模型,不隐藏被 `blockedModels` / `planOnlyModels` 命中的模型**,设置页才能把权限改回来)。
67
+ - `service.peekModelOptions()`:同步零进程返回 `{ value, name }[]`,与 `peekModels()` 同源且**不做策略裁剪**(设置页与快照共用)。
68
+ - `service.getAcpSnapshot()` 的返回值附带 `models: [{ value, name }]`(同源于 `peekModels()`,零启动子进程),供设置页渲染模型权限卡片。
66
69
  - `service.normalizeModelSpec(spec)`:同步校验模型、别名、档位与锁档冲突。
67
70
  - `service.getQuota({ force, includeCredits })` / `service.peekQuota()`:按需运行只读 `agy -p /usage --output-format json`(及可选 `/credits`),具备 5 分钟缓存、single-flight、不可关闭的 45 秒硬超时,失败时保留上一次成功快照并标记 `stale: true`,严格区分数字 `0` 与 `null`。
68
71
 
package/index.js CHANGED
@@ -221,6 +221,32 @@ function assertModelPolicy(config, model, mode) {
221
221
  }
222
222
  }
223
223
 
224
+ /**
225
+ * 给 `action:"models"` 的每项模型打上策略标记(大小写不敏感,与 normalizeModelSet 同源):
226
+ * - `blocked`:被 `Config.blockedModels` 禁用;
227
+ * - `plan-only`:仅允许在 `mode:"plan"` 下使用;
228
+ * - `allow`:无限制。
229
+ *
230
+ * 注意:这里只做「标注」,绝不从目录里剔除模型——模型清单必须保持完整,
231
+ * 用户才能在设置页把被禁用的模型改回允许。
232
+ */
233
+ function annotateModelAccess(config, models) {
234
+ const blocked = normalizeModelSet(config?.blockedModels);
235
+ const planOnly = normalizeModelSet(config?.planOnlyModels);
236
+ const out = [];
237
+ for (const model of Array.isArray(models) ? models : []) {
238
+ if (!model || typeof model.value !== 'string' || !model.value) continue;
239
+ const key = model.value.trim().toLowerCase();
240
+ const access = blocked.has(key) ? 'blocked' : planOnly.has(key) ? 'plan-only' : 'allow';
241
+ out.push({
242
+ value: model.value,
243
+ name: typeof model.name === 'string' && model.name ? model.name : model.value,
244
+ access,
245
+ });
246
+ }
247
+ return out;
248
+ }
249
+
224
250
  /* ------------------------------------------------------------------ */
225
251
  /* 工具 */
226
252
  /* ------------------------------------------------------------------ */
@@ -302,6 +328,46 @@ function createAgyTool({ getService, getConfig, ctx, dshTools }) {
302
328
  const lines = [];
303
329
  const status = String(value?.status ?? 'ok');
304
330
  const workerName = value?.worker ?? args?.name ?? '';
331
+ if (status === 'models') {
332
+ const models = Array.isArray(value?.models) ? value.models : [];
333
+ const blockedCount = models.filter((model) => model?.access === 'blocked').length;
334
+ const planOnlyCount = models.filter((model) => model?.access === 'plan-only').length;
335
+ lines.push(
336
+ 'agy 模型目录(' +
337
+ models.length +
338
+ ' 项,' +
339
+ (value?.cached ? '缓存命中' : '实时探测') +
340
+ ';[blocked] 禁用,[plan-only] 仅 Plan)'
341
+ );
342
+ for (const model of models) {
343
+ const tag =
344
+ model?.access === 'blocked'
345
+ ? ' [blocked]'
346
+ : model?.access === 'plan-only'
347
+ ? ' [plan-only]'
348
+ : '';
349
+ const label = String(model?.value ?? '');
350
+ const name = String(model?.name ?? '');
351
+ lines.push('- ' + label + (name && name !== label ? '(' + name + ')' : '') + tag);
352
+ }
353
+ const efforts = Array.isArray(value?.efforts) ? value.efforts : [];
354
+ lines.push('思考档位: ' + (efforts.length > 0 ? efforts.join(' | ') : '(无,服务端不提供 thought_level)'));
355
+ const modes = Array.isArray(value?.modes) ? value.modes : [];
356
+ if (modes.length > 0) lines.push('运行模式: ' + modes.join(' | '));
357
+ const current = value?.current ?? {};
358
+ if (current.model) {
359
+ lines.push(
360
+ '当前模型: ' + current.model + ' (' + String(current.effort ?? 'default') + ')'
361
+ );
362
+ }
363
+ lines.push(
364
+ '权限策略: 禁用 ' + blockedCount + ' 项 · 仅 Plan ' + planOnlyCount + ' 项'
365
+ );
366
+ lines.push(
367
+ '提示:被标记的模型仍保留在目录里,可在插件设置页「模型权限控制」改成允许 / 仅 Plan / 禁用。'
368
+ );
369
+ return [{ type: 'text', text: lines.join('\n') }];
370
+ }
305
371
  if (status === 'started') {
306
372
  lines.push('agy 后台任务已启动' + (workerName ? '(worker: ' + workerName + ')' : ''));
307
373
  if (value?.job_id) lines.push('job_id: ' + value.job_id);
@@ -410,14 +476,21 @@ function createAgyTool({ getService, getConfig, ctx, dshTools }) {
410
476
  accessMode: policy.accessMode,
411
477
  signal: exec?.signal,
412
478
  });
479
+ const models = annotateModelAccess(config, catalog.models);
413
480
  return {
414
481
  status: 'models',
415
- models: catalog.models,
482
+ models,
416
483
  efforts: catalog.efforts,
417
484
  modes: catalog.modes,
418
485
  current: catalog.current,
419
486
  cached: catalog.cached,
420
487
  executable: config.executable,
488
+ policy: {
489
+ blockedModels: [...normalizeModelSet(config.blockedModels)],
490
+ planOnlyModels: [...normalizeModelSet(config.planOnlyModels)],
491
+ blockedCount: models.filter((model) => model.access === 'blocked').length,
492
+ planOnlyCount: models.filter((model) => model.access === 'plan-only').length,
493
+ },
421
494
  };
422
495
  }
423
496
 
@@ -1050,6 +1123,7 @@ module.exports = {
1050
1123
  toToolResult,
1051
1124
  formatResultReport,
1052
1125
  assertModelPolicy,
1126
+ annotateModelAccess,
1053
1127
  mountAcpRemoteService,
1054
1128
  REMOTE_METHOD_DESCRIPTOR_KEY,
1055
1129
  AgyWorkerService,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agy-acp",
3
3
  "displayName": "Antigravity ACP Worker",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "description": "Antigravity CLI (agy) ACP bridge for DeepSeek Harness: resumable worker turns, background job wakeup, semaphore queuing, four-tier permission gating, zero-sidecar SQLite token ledger, and on-demand quota inspection.",
6
6
  "license": "MIT",
7
7
  "author": "higekibaka",
package/src/acp-client.js CHANGED
@@ -22,7 +22,7 @@ const executableMod = require('./executable.js');
22
22
  const imagesMod = require('./images.js');
23
23
 
24
24
  const PLUGIN_NAME = 'dsh-agy-acp';
25
- const PLUGIN_VERSION = '0.1.1';
25
+ const PLUGIN_VERSION = '0.1.3';
26
26
  const PROTOCOL_VERSION = 1;
27
27
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
28
28
 
@@ -34,7 +34,7 @@ const DEFAULT_MODELS = Object.freeze([
34
34
  { value: 'gemini-4-carbon', name: 'Gemini 4 Carbon' },
35
35
  { value: 'gemini-4-argon', name: 'Gemini 4 Argon' },
36
36
  { value: 'gemini-4-hydrogen', name: 'Gemini 4 Hydrogen' },
37
- { value: 'gemini-pro-orchestrator', name: 'Orchestrator' },
37
+ { value: 'gemini-pro-orchestrator', name: 'Orchestrator (Carbon)' },
38
38
  { value: 'claude-opus-5-5', name: 'Claude Opus 5.5' },
39
39
  { value: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5' },
40
40
  { value: 'gpt-oss-120b-medium', name: 'GPT-OSS 120B (medium)' },
@@ -64,11 +64,13 @@ const DEFAULT_THOUGHT_LEVELS = Object.freeze(['low', 'medium', 'high']);
64
64
  /**
65
65
  * 模型级静态档位能力表:表内模型以表为准,其余模型沿用服务端目录或三档默认。
66
66
  * - gemini-4-hydrogen:官方四档(默认 high),独有 xhigh,拒绝 minimal。
67
- * - gemini-pro-orchestrator:服务端明确不提供 thought_level,任何档位都必须拒绝。
67
+ * - gemini-pro-orchestrator / gemini-pro-orchestrator-argon:服务端明确不提供 thought_level,
68
+ * 任何档位(含 -low/-medium/-high/-xhigh 后缀)都必须拒绝,两者同为官方零思考档位。
68
69
  */
69
70
  const MODEL_THOUGHT_LEVELS = Object.freeze({
70
71
  'gemini-4-hydrogen': Object.freeze(['low', 'medium', 'high', 'xhigh']),
71
72
  'gemini-pro-orchestrator': Object.freeze([]),
73
+ 'gemini-pro-orchestrator-argon': Object.freeze([]),
72
74
  });
73
75
 
74
76
  /** Hydrogen 模型 ID 判定(当前 ACP 目录里的唯一形态)。 */
package/src/protocol.js CHANGED
@@ -255,6 +255,25 @@ function normalizeWorkerItem(raw) {
255
255
  };
256
256
  }
257
257
 
258
+ /** 模型目录项:`{ value, name }`,空 value 丢弃,绝不产生 undefined。 */
259
+ function normalizeModelOption(raw) {
260
+ const value = coerceString(raw?.value, '');
261
+ if (!value) return null;
262
+ const name = coerceString(raw?.name, '');
263
+ return { value, name: name || value };
264
+ }
265
+
266
+ /** 模型目录归一化:旧快照没有 `models` 字段时默认空数组。 */
267
+ function normalizeModelCatalog(raw) {
268
+ if (!Array.isArray(raw)) return [];
269
+ const out = [];
270
+ for (const item of raw) {
271
+ const option = normalizeModelOption(item);
272
+ if (option) out.push(option);
273
+ }
274
+ return out;
275
+ }
276
+
258
277
  function normalizeAcpSnapshot(raw) {
259
278
  return {
260
279
  focusSessionId: coerceString(raw?.focusSessionId, GLOBAL_SESSION_ID),
@@ -282,6 +301,7 @@ function normalizeAcpSnapshot(raw) {
282
301
  activeRuns: Array.isArray(raw?.activeRuns) ? raw.activeRuns.map(normalizeRunItem) : [],
283
302
  recentRuns: Array.isArray(raw?.recentRuns) ? raw.recentRuns.map(normalizeRunItem) : [],
284
303
  workers: Array.isArray(raw?.workers) ? raw.workers.map(normalizeWorkerItem) : [],
304
+ models: normalizeModelCatalog(raw?.models),
285
305
  quota: raw?.quota ? normalizeAcpQuota(raw.quota) : null,
286
306
  };
287
307
  }
@@ -528,6 +548,8 @@ module.exports = {
528
548
  normalizeUsageReconciliation,
529
549
  normalizeRunItem,
530
550
  normalizeWorkerItem,
551
+ normalizeModelOption,
552
+ normalizeModelCatalog,
531
553
  normalizeAcpQuota,
532
554
  normalizeAcpSnapshot,
533
555
  parseSessionRequest,
@@ -1680,6 +1680,9 @@ class AgyWorkerService {
1680
1680
  activeRuns,
1681
1681
  recentRuns,
1682
1682
  workers,
1683
+ // 模型目录(零子进程):完整保留全部已知模型,绝不按 blacklist 裁剪,
1684
+ // 否则设置页取消勾选后无法把被禁用的模型钩回来。
1685
+ models: this.peekModelOptions(),
1683
1686
  quota: this.peekQuota(),
1684
1687
  };
1685
1688
  }
@@ -1975,6 +1978,26 @@ class AgyWorkerService {
1975
1978
  };
1976
1979
  }
1977
1980
 
1981
+ /**
1982
+ * 面板 / 设置页用的模型目录摘要(零子进程),每项形如 `{ value, name }`。
1983
+ *
1984
+ * 与 `peekModels()` 同源,且**不做任何策略裁剪**:`blockedModels` /
1985
+ * `planOnlyModels` 命中的模型也必须留在目录里,前端三态按钮才能再改回来。
1986
+ */
1987
+ peekModelOptions() {
1988
+ const catalog = this.peekModels();
1989
+ const list = Array.isArray(catalog?.models) ? catalog.models : [];
1990
+ const out = [];
1991
+ for (const model of list) {
1992
+ if (!model || typeof model.value !== 'string' || !model.value) continue;
1993
+ out.push({
1994
+ value: model.value,
1995
+ name: typeof model.name === 'string' && model.name ? model.name : model.value,
1996
+ });
1997
+ }
1998
+ return out;
1999
+ }
2000
+
1978
2001
  /**
1979
2002
  * 共享执行入口的模型策略:先按别名/后缀/默认值归一化,再比对黑名单。
1980
2003
  *