@genee/omp-opsx-addon 0.1.0 → 0.3.0

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/index.ts CHANGED
@@ -101,8 +101,7 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
101
101
  // This prevents cross-stream contamination while keeping global totals intact
102
102
  const subagentStreamKey = `${primarySessionId}:subagent:${p.id}`;
103
103
 
104
- // Record usage with subagent-specific stream key (NO setSessionId call)
105
- sessionUsageRecorder.record(provider, usage, subagentStreamKey);
104
+ sessionUsageRecorder.record(canonicalizeProvider(provider), usage, subagentStreamKey);
106
105
  } catch (err) {
107
106
  // Silently fail to avoid breaking the plugin
108
107
  console.error('Failed to handle subagent event:', err);
@@ -121,6 +120,8 @@ import { scoreModelWithProvider } from './lib/model-tiers.js';
121
120
  import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
122
121
  import { createUsageWidget } from './lib/usage-widget.js';
123
122
  import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
123
+ import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
124
+ import { scheduleStatusProbes } from './lib/status-probe.js';
124
125
  import {
125
126
  startSharedPoller,
126
127
  getUnconfirmedProviders,
@@ -692,11 +693,30 @@ export default (pi: ExtensionAPI) => {
692
693
  const reports = state?.reports ?? [];
693
694
  let loggedIn: string[] = [];
694
695
  let authAvailable = false;
696
+ // Three-way dedup union of the "logged in" universe:
697
+ // 1. providers derived from modelRegistry.getAvailable() — models
698
+ // whose provider has auth and is not disabled (covers models.yml
699
+ // config-override apiKeys that authStorage.list() never sees);
700
+ // 2. authStorage.list() — SQLite credential keys (incl. non-catalog
701
+ // providers getAvailable() doesn't surface);
702
+ // 3. locally-detectable direct-fetcher credentials.
703
+ // All ids canonicalized (lowercase) before dedup so one provider
704
+ // never surfaces as two columns / tracks.
705
+ try {
706
+ if (typeof ctx.modelRegistry?.getAvailable === 'function') {
707
+ const available = ctx.modelRegistry.getAvailable();
708
+ authAvailable = true;
709
+ loggedIn = [...new Set(available.map((m) => canonicalizeProvider(m.provider)).filter(Boolean))];
710
+ }
711
+ } catch {
712
+ /* modelRegistry unavailable → fall back to auth.list() + detectable */
713
+ }
695
714
  try {
696
715
  if (auth?.list) {
697
716
  authAvailable = true;
698
717
  const list = auth.list();
699
- loggedIn = Array.isArray(list) ? list : [];
718
+ const ids = Array.isArray(list) ? list : [];
719
+ loggedIn = [...new Set([...loggedIn, ...ids.map(canonicalizeProvider)])];
700
720
  }
701
721
  } catch {
702
722
  /* treat authStorage as unavailable */
@@ -759,13 +779,13 @@ export default (pi: ExtensionAPI) => {
759
779
  consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
760
780
  }
761
781
  if (ctx.hasUI) {
762
- ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates), {
782
+ ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
763
783
  placement: 'belowEditor',
764
784
  });
765
785
  } else {
766
786
  if (ctx.ui) {
767
787
  const cols = (process.stdout.columns ?? 120) - 3;
768
- const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
788
+ const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
769
789
  ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
770
790
  }
771
791
  }
@@ -775,9 +795,37 @@ export default (pi: ExtensionAPI) => {
775
795
  // provider's aggregated consumption RATE (all sessions, main +
776
796
  // subagent via cache backflow), and re-renders the widget. It never
777
797
  // recomputes a selection, never applies overrides, never setModel.
798
+ const cfg = buildEffectiveConfig();
778
799
  const onTick = () => {
800
+ // Fire-and-forget status probe: do not block the tick.
801
+ const { placeholderIds, active } = resolveActiveProviders();
802
+ let models: { id: string; provider: string; baseUrl?: string }[] = [];
803
+ try {
804
+ if (ctx.modelRegistry && typeof ctx.modelRegistry.getAvailable === 'function') {
805
+ models = ctx.modelRegistry.getAvailable() as { id: string; provider: string; baseUrl?: string }[];
806
+ }
807
+ } catch {
808
+ if (ctx.modelRegistry && typeof ctx.modelRegistry.getAll === 'function') {
809
+ try {
810
+ models = ctx.modelRegistry.getAll() as { id: string; provider: string; baseUrl?: string }[];
811
+ } catch {
812
+ models = [];
813
+ }
814
+ }
815
+ }
816
+ void scheduleStatusProbes({
817
+ placeholderIds,
818
+ models,
819
+ getApiKey,
820
+ config: {
821
+ enabled: cfg.status_probe_enabled,
822
+ ttlMs: cfg.probe_ttl_ms,
823
+ timeoutMs: cfg.probe_timeout_ms,
824
+ excluded: cfg.excluded_providers,
825
+ },
826
+ }).then((written) => { if (written > 0) render(); }).catch(() => {});
827
+
779
828
  sessionUsageRecorder.flush();
780
- const { active } = resolveActiveProviders();
781
829
  // Waveform source = per-second consumption sequence (timestamped
782
830
  // deltas aggregated across sessions). Sample EVERY elapsed second
783
831
  // for EVERY active provider — zeros when a second had no
@@ -803,7 +851,6 @@ export default (pi: ExtensionAPI) => {
803
851
  lastSampledSec = lastSec;
804
852
  render();
805
853
  };
806
- const cfg = buildEffectiveConfig();
807
854
  await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
808
855
  onTick: claimsOwnership ? onTick : undefined,
809
856
  ownsUi,
@@ -868,7 +915,7 @@ export default (pi: ExtensionAPI) => {
868
915
  const ev = event.assistantMessageEvent;
869
916
  if ('partial' in ev) {
870
917
  const usage = assistantUsageOf(ev.partial);
871
- if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
918
+ if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
872
919
  }
873
920
  } catch {
874
921
  /* usage recording is best-effort; stay silent */
@@ -879,7 +926,7 @@ export default (pi: ExtensionAPI) => {
879
926
  pi.on('message_end', async (event) => {
880
927
  try {
881
928
  const usage = assistantUsageOf(event.message);
882
- if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
929
+ if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
883
930
  } catch {
884
931
  /* usage recording is best-effort; stay silent */
885
932
  }
@@ -973,6 +1020,10 @@ export default (pi: ExtensionAPI) => {
973
1020
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
974
1021
  ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
975
1022
  : undefined;
1023
+ // 429/403 is the only observable exhaustion evidence for providers
1024
+ // without a usage fetcher — record it in the process-local binary status
1025
+ // so the placeholder column reads 耗尽 (see lib/provider-status.ts).
1026
+ markExhausted(canonical);
976
1027
  // Force-refresh just this provider (bypassing the 10s floor) so the
977
1028
  // diagnosis reflects current quota.
978
1029
  if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
@@ -989,6 +1040,10 @@ export default (pi: ExtensionAPI) => {
989
1040
  await diagnoseProviderError(ctx, `HTTP ${event.status}`, '429');
990
1041
  } else if (event.status === 403) {
991
1042
  await diagnoseProviderError(ctx, `HTTP ${event.status}`, '403');
1043
+ } else if (event.status >= 200 && event.status < 300) {
1044
+ // Strict 2xx restores the provider's binary status. 5xx / other
1045
+ // 4xx are NOT recovery evidence and must not clear exhaustion.
1046
+ if (ctx.model) markAvailable(canonicalizeProvider(ctx.model.provider));
992
1047
  }
993
1048
  } catch (e) {
994
1049
  warn(`[omp-opsx-addon] after_provider_response error: ${e}`);
package/lib/agent-defs.ts CHANGED
@@ -14,6 +14,12 @@ tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
14
14
  1. 从 prompt 或上下文确定要审查的变更
15
15
  2. 读取 proposal、design 和 tasks 文件
16
16
  3. 运行 \`git diff HEAD~1\` 或 \`git diff --stat\` 查看改动
17
+ ## scratchpad.md 共享缓存
18
+ - 审查前 MUST 先读 \`openspec/changes/<name>/scratchpad.md\`(若存在),只读不改写(工具集无 write/edit)。
19
+ - 以「调研与设计」+「实现探索」两区累计的涉及文件 \`path:line\` 集合为代码审查与增量验证范围:Tier 1(typecheck/lint)与 Tier 3(E2E,如适用)聚焦涉及文件改动面;Tier 2 冒烟套件仍全量执行项目现有测试套件。
20
+ - 无 scratchpad.md 时回退:从 proposal/design/tasks 确定审查范围。
21
+ - 识别 \`- [R<n> coder] supersede:\` 条目:以紧随其后的新结论为权威,旧 \`path:line\` 不再作为验证范围;只读不改写。
22
+ - 发现 scratchpad.md 结论与代码现状不符时,作为 P0/P1 审查发现发回,不直接改写。
17
23
 
18
24
  ## 审查维度
19
25
  1. **规范遵循**:是否精确实现了 proposal/design/tasks 定义的内容?有无缺失或偏离?
@@ -73,6 +79,11 @@ tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
73
79
  ## 启动
74
80
  1. 从 prompt 或上下文确定要审查的提案名称
75
81
  2. 读取 proposal.md、design.md(如果存在)
82
+ ## scratchpad.md 共享缓存
83
+ - 审查前 MUST 先读 \`openspec/changes/<name>/scratchpad.md\`(若存在),复用其中调研结论与涉及文件,避免重复 read/grep。
84
+ - 只读,不改写 scratchpad.md;你的工具集不含 write/edit,物理上无法写入。
85
+ - 识别 \`- [R<n> coder] supersede:\` 条目:以紧随其后的新结论为权威,旧结论不再作为调研依据;只读不改写。
86
+ - 发现的 P0/P1 与关注点写入审查报告,由主 agent 中转回流,不直接写 scratchpad.md。
76
87
 
77
88
  ## 审查维度
78
89
  1. **完整性**:Why/What/Capabilities/Impact 是否齐全?有无缺失章节?
@@ -131,6 +142,19 @@ skill: openspec-apply-change
131
142
  - 代码全部完成后将所有未完成项标记为 \`- [x]\`,在 ACTION 报告中说明变更已全部实现
132
143
  - 不修改 \`proposal.md\`、\`design.md\` 或 \`.openspec.yaml\`
133
144
  - 直接用 read/write/edit/bash 等内置工具实现;不要尝试调用 task 二次委派。
145
+ ## scratchpad.md 共享缓存
146
+ - 开工前 MUST 读 \`openspec/changes/<name>/scratchpad.md\`(若存在),复用已记录结论,禁止重复探索。
147
+ - 交付前 MUST 将本轮新探索结论 append 到「实现探索」区(关键符号/数据流、新增涉及文件 \`path:line\`、验证/构建命令、已排除假设),标注 \`- [R<n> coder] <结论>\`。
148
+ - 收到主 agent 中转的 code-reviewer P0/P1 关注点时,开工时先将关注点 append 进「代码审查范围」区,再开始修复。
149
+ - 后续轮次只做增量:仅探索未记录的文件/符号/假设,不重复 read/grep 已记录内容;append-only,不得改写他人结论。
150
+
151
+ ## scratchpad.md 与代码不一致时的两档处置
152
+ 执行中发现代码现实与 scratchpad.md 记录不一致时,按两档客观判据处置;判据仅为「偏差是否影响任何 task 的前提或产出定义」,禁止主观估量「问题大小」:
153
+ - **档一:事实快照过期**——偏差不影响任何 task 的前提或产出定义(纯探索性信息:路径 / 行号 / 符号 / 命令漂移)。处置:继续执行当前任务,不终止;在「实现探索」区 append supersede 修正。
154
+ - **档二:契约动摇**——偏差导致 tasks / proposal / design / specs 的有效性存疑(前提不成立、产出定义变、代码现状与设计决策冲突)。处置:立即停止实现,输出 STATUS: blocked(无 SESSION)并说明冲突点,交由主 agent 裁决(改提案 / 确认「现状即新设计」/ 开新 change)。
155
+ - supersede 标注格式:\`- [R<n> coder] supersede: <旧结论摘要>\`,随后一行以 \`- [R<n> coder]\` 标注新结论;两条均落「实现探索」分区,旧结论保留(append-only),不删除。
156
+ - 交付摘要 SUMMARY 义务:本轮发生过 supersede 时,输出格式中的 SUMMARY 行 MUST 提及本次 supersede 清单。
157
+
134
158
  ## 验证(交付门槛)
135
159
  - 交付前必须跑过 **scoped 到本次改动面** 的 lint、typecheck 与针对性单测(proposal 约定的测试必须通过);任一未通过不得交付,先修到通过再标记 ACTION: REVIEW_REQUIRED。
136
160
  - 项目级全量校验(全仓库 lint/typecheck/\`bun test\`)不属于自验证门槛:若失败可归因于并发 sibling 的半成品改动(与本改动无关),把失败连同「已排查与本改动无关」的证据作为上下文上报(交付摘要中注明),不要被其阻塞;最终由 code-reviewer 的全局验证兜底确认。
@@ -148,6 +172,7 @@ ACTION: REVIEW_REQUIRED
148
172
  CHANGE: <change-name 或 "general">
149
173
  STATUS: success | partial | blocked
150
174
  SESSION: <session_name>(仅 STATUS: blocked 且需 resume 时填写)
175
+ SUMMARY: <交付摘要;若本轮有 supersede,必须包含「supersede: <清单>」>
151
176
  TASKS: N/M complete
152
177
  FILES: <逗号分隔的修改/创建文件列表>
153
178
  ---
@@ -173,6 +198,17 @@ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` :
173
198
  ## 工作范围
174
199
  - **只负责提案文档**:proposal.md、design.md、tasks.md、specs/(openspec/changes/ 目录下)
175
200
  - 直接用内置工具;不要尝试调用 task 二次委派。
201
+ ## scratchpad.md 共享缓存
202
+ 变更目录下维护 \`openspec/changes/<name>/scratchpad.md\` 作四角色共享探索缓存(与 proposal.md/design.md/tasks.md 同级),固定四阶段分区:
203
+ - \`## 调研与设计\`:调研结论、涉及文件(\`path:line\`)、已排除方案
204
+ - \`## 提案审查关注点\`:proposal-reviewer 审查报告中的 P0/P1 与关注点(经主 agent 中转回流)
205
+ - \`## 实现探索\`:coder 每轮 append 的关键符号/数据流、新增涉及文件、验证/构建命令、已排除假设
206
+ - \`## 代码审查范围\`:code-reviewer 审查报告中的 P0/P1 与验证范围结论(经主 agent 中转回流)
207
+ 规则:
208
+ - **出提案时 MUST 创建** \`openspec/changes/<name>/scratchpad.md\` 并写入四阶段骨架,预填「调研与设计」初始骨架:调研结论、涉及文件、已排除方案。
209
+ - 后续轮次修改提案时,先读回 scratchpad.md:将 proposal-reviewer 关注点 append 进「提案审查关注点」区,新调研结论增量 append 进「调研与设计」区。
210
+ - 条目以 \`- [R<n> planner] <结论>\` 标注轮次与角色;append-only,不得改写或删除他人结论。
211
+ - 读回 scratchpad.md 时,以最新 supersede 条目为权威(识别 \`- [R<n> coder] supersede:\` 标记,其后的新结论优先于旧结论)。
176
212
 
177
213
  ## 提案拆分
178
214
  - 接大需求先评估契约边界:能拆则拆成多个小变更,先输出拆分建议(契约边界+提案清单)待主 agent 确认后再开写。
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Process-local binary status for providers WITHOUT a usage report.
3
+ *
4
+ * A provider that has no usage fetcher has no authoritative quota source; the
5
+ * only observable exhaustion evidence is a 429/403 signal on the provider
6
+ * response or tool-execution paths. This module keeps that signal as a
7
+ * process-local `Map` (mirroring the `regionBlockedModels` in-process precedent
8
+ * in index.ts): absence of an entry means "可用", an explicit `markExhausted`
9
+ * flips it to "耗尽", and a subsequent strict-2xx provider response restores
10
+ * "可用". Nothing is persisted and nothing crosses processes — a 429 observed
11
+ * in one process is not broadcast to another, which is an accepted trade-off
12
+ * for a best-effort local hint.
13
+ *
14
+ * Keys are canonicalized (lowercase) so `ArkCodingPlan` and `arkcodingplan`
15
+ * share one entry.
16
+ */
17
+
18
+ import { canonicalizeProvider } from './usage-resolver.js';
19
+
20
+ interface ProviderStatus {
21
+ exhausted: boolean;
22
+ observedAt: number;
23
+ }
24
+
25
+ const state = new Map<string, ProviderStatus>();
26
+
27
+ /** Mark a provider exhausted (429/403 observed). */
28
+ export function markExhausted(provider: string): void {
29
+ const canonical = canonicalizeProvider(provider);
30
+ state.set(canonical, { exhausted: true, observedAt: Date.now() });
31
+ }
32
+
33
+ /** Clear the exhausted marker (strict 2xx response observed). */
34
+ export function markAvailable(provider: string): void {
35
+ state.delete(canonicalizeProvider(provider));
36
+ }
37
+
38
+ /** True iff the provider has an explicit exhaustion marker (default: available). */
39
+ export function isExhausted(provider: string): boolean {
40
+ return state.get(canonicalizeProvider(provider))?.exhausted ?? false;
41
+ }
42
+
43
+ /** Canonical ids of all currently-exhausted providers. */
44
+ export function getExhaustedProviders(): ReadonlySet<string> {
45
+ const out = new Set<string>();
46
+ for (const [id, s] of state) {
47
+ if (s.exhausted) out.add(id);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Test seam: clear all process-local state. */
53
+ export function _resetForTest(): void {
54
+ state.clear();
55
+ }
@@ -183,7 +183,7 @@ export interface HttpProbeOptions {
183
183
  }
184
184
 
185
185
  /** 区域封锁判定的关键词 */
186
- const REGION_BLOCK_TOKENS = ['region', 'not available', 'in your area'];
186
+ export const REGION_BLOCK_TOKENS = ['region', 'not available', 'in your area'];
187
187
 
188
188
  /**
189
189
  * 对 TLS 可达的端点发送最小 HTTP 请求(1 token chat completion),
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Provider 状态探测模块。
3
+ *
4
+ * 对无 usage fetcher 的 provider(如 ArkCodingPlan)主动发最小探测请求,
5
+ * 区分「429 类错误 vs 正常模型」,驱动底部 widget「可用/耗尽」二元态。
6
+ *
7
+ * 探测是 fire-and-forget 异步操作,绝不阻塞渲染路径。结果写入 provider-status.ts
8
+ * 的进程内状态机(exhausted 集合),渲染自动读取。
9
+ *
10
+ * 探测分类:
11
+ * - ok(2xx)→ markAvailable(清除耗尽)
12
+ * - exhausted(429,或 403 非 region)→ markExhausted
13
+ * - region-blocked(403 + region 关键词)→ markExhausted(归耗尽显示)
14
+ * - auth-error(401)/ model-not-found(404)/ network-error(异常/超时)→ 不写状态
15
+ * (宁缺毋谎:无法区分「provider 真不可用」与「探测请求构造失真」)
16
+ *
17
+ * Cooldown ledger:进程内 Map<canonicalProvider, lastProbedAt>,复用 probe_ttl_ms。
18
+ * markStatusProbeStarted 在任何 await 之前写入,彻底关死 in-flight 竞态窗口。
19
+ */
20
+
21
+ import { REGION_BLOCK_TOKENS } from './reachability-probe.js';
22
+ import { canonicalizeProvider } from './usage-resolver.js';
23
+ import { markExhausted, markAvailable } from './provider-status.js';
24
+
25
+ // ── 类型 ────────────────────────────────────────────────────────────────
26
+
27
+ /** 状态探测的分类结果。 */
28
+ export type StatusProbeClass =
29
+ | 'ok' // 2xx:真实模型、真实 key,请求走通
30
+ | 'exhausted' // 429,或 403 非 region 文本
31
+ | 'region-blocked' // 403 + region 关键词
32
+ | 'auth-error' // 401
33
+ | 'model-not-found' // 404
34
+ | 'network-error'; // fetch 抛异常 / 超时
35
+
36
+ /** 状态探测结果。 */
37
+ export interface StatusProbeResult {
38
+ provider: string;
39
+ class: StatusProbeClass;
40
+ detail?: string;
41
+ probedAt: number;
42
+ }
43
+
44
+ /** 网络探测选项。 */
45
+ export interface StatusProbeOptions {
46
+ baseUrl: string;
47
+ model: string;
48
+ apiKey: string;
49
+ timeoutMs: number;
50
+ fetchImpl?: typeof fetch;
51
+ }
52
+
53
+ /** 调度配置。 */
54
+ export interface ScheduleConfig {
55
+ enabled: boolean;
56
+ ttlMs: number;
57
+ timeoutMs: number;
58
+ excluded: string[];
59
+ }
60
+
61
+ /** 调度选项。 */
62
+ export interface ScheduleOptions {
63
+ placeholderIds: string[];
64
+ models: { id: string; provider: string; baseUrl?: string }[];
65
+ getApiKey?: (provider: string) => Promise<string | undefined>;
66
+ config: ScheduleConfig;
67
+ fetchImpl?: typeof fetch;
68
+ }
69
+
70
+ // ── 纯分类函数 ─────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * 纯分类:HTTP 状态 + 响应体文本 → 分类。
74
+ * 无 IO,直接单测。
75
+ */
76
+ export function classifyStatusResponse(status: number, bodyText: string): StatusProbeClass {
77
+ // 2xx → ok
78
+ if (status >= 200 && status < 300) {
79
+ return 'ok';
80
+ }
81
+ // 429 → exhausted
82
+ if (status === 429) {
83
+ return 'exhausted';
84
+ }
85
+ // 403 + region 关键词 → region-blocked,否则 exhausted
86
+ if (status === 403) {
87
+ const lower = bodyText.toLowerCase();
88
+ if (REGION_BLOCK_TOKENS.some((tok) => lower.includes(tok))) {
89
+ return 'region-blocked';
90
+ }
91
+ return 'exhausted';
92
+ }
93
+ // 401 → auth-error
94
+ if (status === 401) {
95
+ return 'auth-error';
96
+ }
97
+ // 404 → model-not-found
98
+ if (status === 404) {
99
+ return 'model-not-found';
100
+ }
101
+ // 其余一律 → network-error(detail 记原始 status)
102
+ return 'network-error';
103
+ }
104
+
105
+ // ── 网络探测函数 ───────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * 对单个 provider 执行状态探测。
109
+ * 绝不抛异常:所有错误/超时/解析失败 → network-error 结果。
110
+ */
111
+ export async function probeProviderStatus(
112
+ provider: string,
113
+ opts: StatusProbeOptions,
114
+ ): Promise<StatusProbeResult> {
115
+ const fetchFn = opts.fetchImpl ?? fetch;
116
+ const url = `${opts.baseUrl.replace(/\/+$/, '')}/chat/completions`;
117
+
118
+ try {
119
+ const response = await fetchFn(url, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ 'Authorization': `Bearer ${opts.apiKey}`,
124
+ },
125
+ body: JSON.stringify({
126
+ model: opts.model,
127
+ messages: [{ role: 'user', content: 'hi' }],
128
+ max_tokens: 1,
129
+ }),
130
+ signal: AbortSignal.timeout(opts.timeoutMs),
131
+ });
132
+
133
+ let bodyText = '';
134
+ try {
135
+ bodyText = await response.text();
136
+ } catch {
137
+ // 响应体读取失败 → 按空文本处理
138
+ bodyText = '';
139
+ }
140
+
141
+ const classification = classifyStatusResponse(response.status, bodyText);
142
+ return {
143
+ provider,
144
+ class: classification,
145
+ detail: classification === 'network-error' ? `status ${response.status}` : undefined,
146
+ probedAt: Date.now(),
147
+ };
148
+ } catch (e: unknown) {
149
+ // 网络/超时/AbortError → network-error,绝不抛
150
+ const err = e as Error & { name?: string };
151
+ const detail = err.name === 'AbortError' || err.name === 'TimeoutError'
152
+ ? 'timeout'
153
+ : (err.message ?? String(e));
154
+ return {
155
+ provider,
156
+ class: 'network-error',
157
+ detail,
158
+ probedAt: Date.now(),
159
+ };
160
+ }
161
+ }
162
+
163
+ // ── Cooldown ledger ─────────────────────────────────────────────────────
164
+
165
+ const probeLedger = new Map<string, number>();
166
+
167
+ /** 检查是否已到下次探测时间。 */
168
+ export function isStatusProbeDue(provider: string, ttlMs: number): boolean {
169
+ const canonical = canonicalizeProvider(provider);
170
+ const lastProbedAt = probeLedger.get(canonical);
171
+ if (lastProbedAt === undefined) {
172
+ return true;
173
+ }
174
+ return Date.now() - lastProbedAt >= ttlMs;
175
+ }
176
+
177
+ /** 标记探测已开始(在任何 await 之前调用,关死竞态窗口)。 */
178
+ export function markStatusProbeStarted(provider: string): void {
179
+ const canonical = canonicalizeProvider(provider);
180
+ probeLedger.set(canonical, Date.now());
181
+ }
182
+
183
+ /** 测试 seam:清空 ledger。 */
184
+ export function _resetForTest(): void {
185
+ probeLedger.clear();
186
+ }
187
+
188
+ // ── 调度入口 ─────────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * 调度状态探测。
192
+ *
193
+ * 探测是 fire-and-forget 异步操作,绝不阻塞调用路径。
194
+ * 返回写入计数(可能为任意非负整数),调用方可据此判断是否需要重渲染。
195
+ *
196
+ * 异常全部吞没:fetch 失败/超时/解析失败/状态机写入失败均不影响其他 provider。
197
+ */
198
+ export async function scheduleStatusProbes(opts: ScheduleOptions): Promise<number> {
199
+ const { placeholderIds, models, getApiKey, config, fetchImpl } = opts;
200
+
201
+ // 快速短路:未启用或无可用的 getApiKey → 零请求,不抛 TypeError
202
+ if (!config.enabled || getApiKey === undefined) {
203
+ return 0;
204
+ }
205
+
206
+ // 同步过滤候选:excluded + 无 baseUrl/无模型
207
+ const excludedSet = new Set(config.excluded.map(canonicalizeProvider));
208
+ const modelMap = new Map<string, { id: string; baseUrl: string }>();
209
+ for (const m of models) {
210
+ if (m.baseUrl && !modelMap.has(canonicalizeProvider(m.provider))) {
211
+ modelMap.set(canonicalizeProvider(m.provider), { id: m.id, baseUrl: m.baseUrl });
212
+ }
213
+ }
214
+
215
+ const candidates = placeholderIds.filter((id) => {
216
+ const canonical = canonicalizeProvider(id);
217
+ if (excludedSet.has(canonical)) {
218
+ return false;
219
+ }
220
+ const modelInfo = modelMap.get(canonical);
221
+ if (!modelInfo) {
222
+ return false;
223
+ }
224
+ // cooldown 检查
225
+ if (!isStatusProbeDue(id, config.ttlMs)) {
226
+ return false;
227
+ }
228
+ return true;
229
+ });
230
+
231
+ if (candidates.length === 0) {
232
+ return 0;
233
+ }
234
+
235
+ // 对每个候选:先 markStatusProbeStarted(占位),再 await getApiKey
236
+ // 关死 in-flight 竞态窗口
237
+ const probePromises = candidates.map(async (provider) => {
238
+ markStatusProbeStarted(provider);
239
+ const apiKey = await getApiKey(provider);
240
+ if (!apiKey) {
241
+ // 无 key → 跳过请求但保留 cooldown 占位(下个 TTL 再查)
242
+ return null;
243
+ }
244
+ const modelInfo = modelMap.get(canonicalizeProvider(provider));
245
+ if (!modelInfo) {
246
+ return null;
247
+ }
248
+ return probeProviderStatus(provider, {
249
+ baseUrl: modelInfo.baseUrl,
250
+ model: modelInfo.id,
251
+ apiKey,
252
+ timeoutMs: config.timeoutMs,
253
+ fetchImpl,
254
+ });
255
+ });
256
+
257
+ // 并发探测,单个失败不连坐
258
+ const results = await Promise.allSettled(probePromises);
259
+ let written = 0;
260
+
261
+ for (const result of results) {
262
+ if (result.status === 'fulfilled') {
263
+ const probeResult = result.value;
264
+ if (!probeResult) {
265
+ continue; // 跳过的 provider
266
+ }
267
+ const canonical = canonicalizeProvider(probeResult.provider);
268
+ // 映射到状态机:exhausted/region-blocked → markExhausted;ok → markAvailable
269
+ if (probeResult.class === 'exhausted' || probeResult.class === 'region-blocked') {
270
+ markExhausted(probeResult.provider);
271
+ written++;
272
+ console.info(`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}`);
273
+ } else if (probeResult.class === 'ok') {
274
+ markAvailable(probeResult.provider);
275
+ written++;
276
+ console.info(`[omp-opsx-addon] status probe: ${canonical} -> ok`);
277
+ } else {
278
+ // auth-error/model-not-found/network-error 不写状态
279
+ console.warn(
280
+ `[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}${probeResult.detail ? ` (${probeResult.detail})` : ''}`,
281
+ );
282
+ }
283
+ }
284
+ // rejected promise 在理论上不应发生(probeProviderStatus 已吞没所有异常),防御性忽略
285
+ }
286
+
287
+ return written;
288
+ }
@@ -100,15 +100,15 @@ export function buildStaticPrompt(): string {
100
100
  | 审查代码实现 | task(agent:"reviewer", task:"审阅代码 + 执行全局验证(lint/单测/e2e)") |
101
101
 
102
102
  ## 2 个 Loop
103
- - **Loop 1 – Propose → Review**:planner 完成 → proposal-reviewer → P0/P1 发回 → 通过告知用户。Propose-review loop:最多 planner→reviewer 往复 2 轮。
104
- - **Loop 2 – Code → Review**:coder 完成(交付前自验证)→ code-reviewer 审阅 + 全局验证 → P0/P1 发回修复 → 仅 P2+ 视为通过。Code-review loop:最多 coder→reviewer 往复 3 轮(含首次实现),reviewer 兼执行全局验证。
103
+ - **Loop 1 – Propose → Review**:planner 完成 → proposal-reviewer → P0/P1 发回 → 通过告知用户。Propose-review loop:最多 planner→reviewer 往复 2 轮。委派 planner/proposal-reviewer 的 task 描述须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
104
+ - **Loop 2 – Code → Review**:coder 完成(交付前自验证)→ code-reviewer 审阅 + 全局验证 → P0/P1 发回修复 → 仅 P2+ 视为通过。Code-review loop:最多 coder→reviewer 往复 3 轮(含首次实现),reviewer 兼执行全局验证。委派 coder/code-reviewer 的 task 描述须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
105
105
  - 达上限时停止自动流转,向用户说明未解决的问题并请求决策。
106
106
 
107
107
  ## /goal 集成(可选)
108
108
  Planner 在 proposal 末尾输出 \`## Budget Estimate\`;主 agent 提取用于 \`/goal\`。
109
109
 
110
110
  ## 自动流程协作
111
- coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到 P0/P1 → 修复再审。
111
+ coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到 P0/P1 → 修复再审。P0/P1 发回修复时 task 描述同样须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
112
112
 
113
113
  ## 上下文卫生(主 session 预算)
114
114
  - 代码调研/搜代码/理解架构 → task(agent:"scout");主 session 不亲自 read 全文调研。
@@ -118,7 +118,7 @@ coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到
118
118
  - 主 session 上下文超过 ~50k tokens:必须先委托再继续,禁止继续亲自调研。`;
119
119
  }
120
120
 
121
- const STATIC_LINE_COUNT = buildStaticPrompt().split('\n').length;
121
+ export const STATIC_LINE_COUNT = buildStaticPrompt().split('\n').length;
122
122
 
123
123
  /** Dynamic layer: Dispatch config + change status. Placed after the static
124
124
  * layer so only this tail is re-prefilled when it changes between turns. */
@@ -66,6 +66,7 @@ export interface OpsxYamlShape {
66
66
  probe_http_enabled?: boolean;
67
67
  probe_ttl_ms?: number;
68
68
  excluded_providers?: string[];
69
+ status_probe_enabled?: boolean;
69
70
  redis_enabled?: boolean;
70
71
  redis_host?: string;
71
72
  redis_port?: number;
@@ -102,6 +103,8 @@ export interface ResolvedOpsxConfig {
102
103
  probe_ttl_ms: number;
103
104
  /** 静态排除的 provider 列表(零探测成本) */
104
105
  excluded_providers: string[];
106
+ /** 是否开启状态探测,默认 true */
107
+ status_probe_enabled: boolean;
105
108
  /** 是否启用本地 Redis 实时中转(默认 true;false 时纯文件路径)。 */
106
109
  redis_enabled: boolean;
107
110
  /** Redis host(默认 127.0.0.1)。 */
@@ -309,6 +312,7 @@ export function parseOpsxConfig(
309
312
  probe_http_enabled: parseProbeBool(obj.probe_http_enabled, 'probe_http_enabled', false, localWarn),
310
313
  probe_ttl_ms: parsePositiveInt(obj.probe_ttl_ms, 'probe_ttl_ms', 600_000, localWarn),
311
314
  excluded_providers: parseStringArray(obj.excluded_providers, 'excluded_providers', localWarn),
315
+ status_probe_enabled: parseProbeBool(obj.status_probe_enabled, 'status_probe_enabled', true, localWarn),
312
316
  redis_enabled: parseProbeBool(obj.redis_enabled, 'redis_enabled', true, localWarn),
313
317
  redis_host: redisHost,
314
318
  redis_port: parsePositiveInt(obj.redis_port, 'redis_port', 6379, localWarn),
@@ -359,6 +363,7 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
359
363
  probe_http_enabled: raw.probe_http_enabled,
360
364
  probe_ttl_ms: raw.probe_ttl_ms,
361
365
  excluded_providers: raw.excluded_providers,
366
+ status_probe_enabled: raw.status_probe_enabled,
362
367
  redis_enabled: raw.redis_enabled,
363
368
  redis_host: raw.redis_host,
364
369
  redis_port: raw.redis_port,
@@ -449,6 +454,7 @@ export function readOpsxSettingsFromPaths(
449
454
  merged.probe_http_enabled = projectTop.probe_http_enabled ?? globalTop.probe_http_enabled;
450
455
  merged.probe_ttl_ms = projectTop.probe_ttl_ms ?? globalTop.probe_ttl_ms;
451
456
  merged.excluded_providers = projectTop.excluded_providers ?? globalTop.excluded_providers;
457
+ merged.status_probe_enabled = projectTop.status_probe_enabled ?? globalTop.status_probe_enabled;
452
458
  merged.redis_enabled = projectTop.redis_enabled ?? globalTop.redis_enabled;
453
459
  merged.redis_host = projectTop.redis_host ?? globalTop.redis_host;
454
460
  merged.redis_port = projectTop.redis_port ?? globalTop.redis_port;
@@ -289,18 +289,33 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
289
289
  };
290
290
 
291
291
  /**
292
- * Render a logged-in provider that has no report yet as a header + a single
293
- * placeholder row (`—`). Placeholder identity is always carried by an explicit id
294
- * (see `renderUsageReports` `placeholderIds`); it is NEVER inferred from an empty
295
- * `limits` array, so a real fetch that returns no windows is not mistaken for a
296
- * logged-in-but-unfetched provider.
292
+ * Render a real report whose `limits` array is empty — a fetch succeeded but
293
+ * returned no usable windows. This is NOT a logged-in-but-unfetched provider:
294
+ * it keeps the existing `—` marker (never the binary 可用/耗尽 body) so its
295
+ * rendering is byte-identical to the pre-binary-status behavior.
297
296
  */
298
- export const buildPlaceholderColumn = (provider: string, painter: Painter = ansiPainter, loading = false): string[] => {
297
+ export const buildEmptyReportColumn = (provider: string, painter: Painter = ansiPainter, loading = false): string[] => {
299
298
  const { label, color } = providerLabel(provider);
300
- // Loading placeholder: body shows ⟳ (fetch in flight) instead of the idle —.
301
299
  return [painter.header(loading ? `${label}⟳` : label, color), painter.balance(loading ? "⟳" : "—")];
302
300
  }
303
301
 
302
+ /**
303
+ * Render a logged-in provider that has no report yet as a header + a single
304
+ * binary-status row (`可用` / `耗尽` / `⟳`). Placeholder identity is always
305
+ * carried by an explicit id (see `renderUsageReports` `placeholderIds`); it is
306
+ * NEVER inferred from an empty `limits` array, so a real fetch that returns no
307
+ * windows is not mistaken for a logged-in-but-unfetched provider. `exhausted`
308
+ * reflects the process-local 429/403 signal (see `lib/provider-status.ts`).
309
+ */
310
+ export const buildPlaceholderColumn = (provider: string, painter: Painter = ansiPainter, loading = false, exhausted = false): string[] => {
311
+ const { label, color } = providerLabel(provider);
312
+ const body = loading ? "⟳" : exhausted ? "耗尽" : "可用";
313
+ return [
314
+ painter.header(loading ? `${label}⟳` : label, color),
315
+ exhausted ? painter.exhausted(body) : painter.balance(body),
316
+ ];
317
+ }
318
+
304
319
  // ── flex-wrap table layout ───────────────────────────────────────────
305
320
 
306
321
  const SEP_VISUAL = 3;
@@ -314,6 +329,7 @@ export function renderUsageReports(
314
329
  loadingProviders?: ReadonlySet<string>,
315
330
  consumptionTracks?: Map<string, ConsumptionTrack>,
316
331
  usageEstimates?: Map<string, string>,
332
+ exhaustedProviders?: ReadonlySet<string>,
317
333
  ): string[] {
318
334
  // Placeholder providers are logged-in but unfetched this round. Their ids are
319
335
  // explicit (never inferred from `limits.length === 0`); dedupe against reports
@@ -329,7 +345,7 @@ export function renderUsageReports(
329
345
  interface ProviderColumn { provider: string; col: string[] }
330
346
  const cols: ProviderColumn[] = (reports ?? []).map((r) => {
331
347
  const col = r.limits.length === 0
332
- ? buildPlaceholderColumn(r.provider, painter, loading.has(r.provider))
348
+ ? buildEmptyReportColumn(r.provider, painter, loading.has(r.provider))
333
349
  : buildColumn(r, painter, loading.has(r.provider));
334
350
  if (!col) return { provider: r.provider, col: [] };
335
351
  // Second-level estimate override replaces the usage line (balance style).
@@ -338,7 +354,7 @@ export function renderUsageReports(
338
354
  return { provider: r.provider, col };
339
355
  });
340
356
  for (const id of placeholders) {
341
- cols.push({ provider: id, col: buildPlaceholderColumn(id, painter, loading.has(id)) });
357
+ cols.push({ provider: id, col: buildPlaceholderColumn(id, painter, loading.has(id), exhaustedProviders?.has(id)) });
342
358
  }
343
359
  const valid = cols.filter((c) => c.col.length > 0);
344
360
  if (valid.length === 0) return [];
@@ -41,9 +41,16 @@ export interface DirectFetcher {
41
41
  */
42
42
  const PROVIDER_ALIAS: Record<string, string> = {};
43
43
 
44
- /** Normalize a provider id to its canonical form (no-op if already canonical). */
44
+ /**
45
+ * Normalize a provider id to its canonical form: lowercase first, then alias
46
+ * mapping. models.yml config keys are mixed-case (`ArkCodingPlan`), DB
47
+ * credentials and message events are lowercase (`arkcodingplan`), and the
48
+ * harness compares ids case-insensitively — lowercase unifies all three so a
49
+ * provider never surfaces as two columns or two waveform tracks.
50
+ */
45
51
  export function canonicalizeProvider(id: string): string {
46
- return PROVIDER_ALIAS[id] ?? id;
52
+ const k = id.toLowerCase();
53
+ return PROVIDER_ALIAS[k] ?? k;
47
54
  }
48
55
 
49
56
  /** Providers whose direct fetcher reports locally-detectable credentials (no network). */
@@ -55,6 +55,7 @@ export class UsageTable implements Component {
55
55
  readonly #loadingProviders?: ReadonlySet<string>;
56
56
  readonly #consumptionTracks?: Map<string, ConsumptionTrack>;
57
57
  readonly #usageEstimates?: Map<string, string>;
58
+ readonly #exhaustedProviders?: ReadonlySet<string>;
58
59
 
59
60
  constructor(
60
61
  reports: UsageReport[],
@@ -63,6 +64,7 @@ export class UsageTable implements Component {
63
64
  loadingProviders?: ReadonlySet<string>,
64
65
  consumptionTracks?: Map<string, ConsumptionTrack>,
65
66
  usageEstimates?: Map<string, string>,
67
+ exhaustedProviders?: ReadonlySet<string>,
66
68
  ) {
67
69
  this.#reports = reports;
68
70
  this.#painter = painter;
@@ -70,6 +72,7 @@ export class UsageTable implements Component {
70
72
  this.#loadingProviders = loadingProviders;
71
73
  this.#consumptionTracks = consumptionTracks;
72
74
  this.#usageEstimates = usageEstimates;
75
+ this.#exhaustedProviders = exhaustedProviders;
73
76
  }
74
77
 
75
78
  render(width: number): readonly string[] {
@@ -83,6 +86,7 @@ export class UsageTable implements Component {
83
86
  this.#loadingProviders,
84
87
  this.#consumptionTracks,
85
88
  this.#usageEstimates,
89
+ this.#exhaustedProviders,
86
90
  );
87
91
  }
88
92
 
@@ -107,6 +111,7 @@ export function createUsageWidget(
107
111
  loadingProviders?: ReadonlySet<string>,
108
112
  consumptionTracks?: Map<string, ConsumptionTrack>,
109
113
  usageEstimates?: Map<string, string>,
114
+ exhaustedProviders?: ReadonlySet<string>,
110
115
  ): ExtensionUiComponentFactory {
111
- return (_tui, theme) => new UsageTable(reports, themePainter(theme), placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
116
+ return (_tui, theme) => new UsageTable(reports, themePainter(theme), placeholderIds, loadingProviders, consumptionTracks, usageEstimates, exhaustedProviders);
112
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genee/omp-opsx-addon",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi Extension: OpenSpec workflow orchestration - coder/reviewer/planner agents, session title & progress",
6
6
  "main": "./index.ts",
@@ -25,10 +25,14 @@
25
25
  "opsx"
26
26
  ],
27
27
  "pi": {
28
- "extensions": ["./index.ts"]
28
+ "extensions": [
29
+ "./index.ts"
30
+ ]
29
31
  },
30
32
  "omp": {
31
- "extensions": ["./index.ts"]
33
+ "extensions": [
34
+ "./index.ts"
35
+ ]
32
36
  },
33
37
  "license": "MIT",
34
38
  "peerDependencies": {