@teamvibe/poller 0.8.3 → 0.8.4-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +13 -0
  2. package/dist/brain-directory.d.ts +14 -0
  3. package/dist/brain-directory.js +16 -0
  4. package/dist/brain-manager.d.ts +2 -2
  5. package/dist/brain-manager.js +32 -19
  6. package/dist/brain-path.d.ts +9 -0
  7. package/dist/brain-path.js +65 -0
  8. package/dist/claude-spawner.d.ts +3 -2
  9. package/dist/claude-spawner.js +195 -74
  10. package/dist/config.d.ts +4 -0
  11. package/dist/config.js +1 -0
  12. package/dist/event-queue.js +14 -1
  13. package/dist/inbox-manager.d.ts +11 -0
  14. package/dist/inbox-manager.js +101 -6
  15. package/dist/inbox-watcher.d.ts +9 -4
  16. package/dist/inbox-watcher.js +219 -49
  17. package/dist/maintenance-cron.d.ts +4 -2
  18. package/dist/maintenance-cron.js +56 -14
  19. package/dist/metrics/scheduler.d.ts +4 -1
  20. package/dist/metrics/scheduler.js +8 -6
  21. package/dist/poller.d.ts +2 -1
  22. package/dist/poller.js +50 -24
  23. package/dist/pool-logging.js +2 -2
  24. package/dist/pool-telemetry.d.ts +21 -5
  25. package/dist/pool-telemetry.js +28 -11
  26. package/dist/session-store.d.ts +1 -1
  27. package/dist/session-store.js +6 -2
  28. package/dist/startup-auth.js +4 -1
  29. package/dist/subscription-pool-client.js +7 -0
  30. package/dist/subscription-pool.d.ts +1 -0
  31. package/dist/subscription-pool.js +1 -1
  32. package/dist/subscription-runtime.d.ts +21 -3
  33. package/dist/subscription-runtime.js +17 -9
  34. package/dist/subscription-selection.d.ts +20 -3
  35. package/dist/subscription-selection.js +66 -4
  36. package/dist/subscription-usage-store.d.ts +2 -0
  37. package/dist/subscription-usage-store.js +41 -20
  38. package/dist/thread-routing.d.ts +23 -0
  39. package/dist/thread-routing.js +81 -0
  40. package/dist/types.d.ts +4 -10
  41. package/dist/usage-limit-messages.d.ts +1 -1
  42. package/dist/usage-limit-messages.js +1 -1
  43. package/dist/usage-probe-contract.d.ts +15 -0
  44. package/dist/usage-probe-contract.js +14 -0
  45. package/dist/usage-probe-job.js +20 -7
  46. package/dist/worktree-manager.d.ts +2 -1
  47. package/dist/worktree-manager.js +38 -10
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
@@ -2,13 +2,13 @@ import { type PoolInfoLogger } from './pool-logging.js';
2
2
  import { type SubscriptionAccount } from './subscription-pool-client.js';
3
3
  import { type ResolvedPoolModel } from './subscription-pool.js';
4
4
  import { FileSubscriptionUsageStore, type SubscriptionUsageStore } from './subscription-usage-store.js';
5
- import { type PoolFetchResult } from './subscription-selection.js';
5
+ import { type PoolFetchResult, type HeadroomScore } from './subscription-selection.js';
6
6
  import type { SubscriptionPin, TeamVibeQueueMessage, SessionMetrics } from './types.js';
7
7
  import type { PoolUsageLimitNotice } from './usage-limit-messages.js';
8
8
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
9
9
  import type { ClaudeQuotaFailure } from './claude-quota.js';
10
10
  import type { KilledByPoller } from './classify-failed-iteration.js';
11
- export interface PoolAttempt {
11
+ export interface PoolAttempt extends Partial<HeadroomScore> {
12
12
  workspaceId: string;
13
13
  accountId: string;
14
14
  accountLabel?: string;
@@ -46,12 +46,30 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
46
46
  account: SubscriptionAccount;
47
47
  store: FileSubscriptionUsageStore;
48
48
  pool: PoolFetchResult<SubscriptionAccount>;
49
+ selection: ({
50
+ kind: "selected";
51
+ account: SubscriptionAccount;
52
+ provenance: "known";
53
+ source: "passive" | "probe";
54
+ } & HeadroomScore & {
55
+ model: string | undefined;
56
+ modelSource: import("./claude-spawner.js").ModelSource;
57
+ }) | ({
58
+ kind: "selected";
59
+ account: SubscriptionAccount;
60
+ provenance: "unknown";
61
+ uncertainApplicability: boolean;
62
+ } & {
63
+ model: string | undefined;
64
+ modelSource: import("./claude-spawner.js").ModelSource;
65
+ });
49
66
  } | {
50
67
  account: undefined;
51
68
  store: FileSubscriptionUsageStore;
52
69
  pool: PoolFetchResult<SubscriptionAccount>;
70
+ selection: undefined;
53
71
  }>;
54
72
  export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
55
73
  export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;
56
- /** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
74
+ /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
57
75
  export declare function poolDeferredError(pool: PoolFetchResult<SubscriptionAccount>, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, eligibleAt?: number, log?: PoolInfoLogger): Promise<PoolDeferredError>;
@@ -6,7 +6,7 @@ import { getPollerId } from './auth-provider.js';
6
6
  import { fetchSubscriptionPool } from './subscription-pool-client.js';
7
7
  import { selectPoolForResolvedModel } from './subscription-pool.js';
8
8
  import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
9
- import { PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
9
+ import { normalizePassiveUsage, selectSubscriptionAccount, PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
10
10
  export class PoolDeferredError extends Error {
11
11
  eligibleAt;
12
12
  resetAt;
@@ -39,20 +39,20 @@ export async function preparePool(msg, resolved, pin, fetched, log) {
39
39
  if (other.accountId !== account.accountId)
40
40
  poolInfo(log, `skipped account=${poolLabel(other)} reason=session pinned elsewhere`);
41
41
  }
42
- return { account, store, pool };
42
+ return { account, store, pool, selection: selected };
43
43
  }
44
44
  }
45
45
  }
46
- const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log });
46
+ const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId: pin?.workspaceId === msg.teamvibe.workspaceId ? pin.accountId : undefined });
47
47
  if (selected.kind === 'selected') {
48
48
  if (log && pool.kind === 'ready' && pin?.workspaceId === msg.teamvibe.workspaceId && pin.accountId !== selected.account.accountId) {
49
49
  const previous = pool.accounts.find(a => a.accountId === pin.accountId);
50
50
  poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=pinned account unavailable`);
51
51
  }
52
- return { account: selected.account, store, pool };
52
+ return { account: selected.account, store, pool, selection: selected };
53
53
  }
54
54
  if (selected.kind === 'no_accounts_configured')
55
- return { account: undefined, store, pool };
55
+ return { account: undefined, store, pool, selection: undefined };
56
56
  throw await poolDeferredError(pool, resolved, store, selected.kind === 'deferred' ? selected.eligibleAt : undefined);
57
57
  }
58
58
  export function accountPin(account, resolved) {
@@ -64,7 +64,7 @@ export async function validateAccount(account, resolved) {
64
64
  throw new PoolDeferredError(selected.kind === 'deferred' ? selected.eligibleAt : undefined);
65
65
  }
66
66
  /** Message-only evidence; never changes selection, eligibility or durable pause state. */
67
- async function poolUsageNotice(pool, store) {
67
+ async function poolUsageNotice(pool, resolved, store) {
68
68
  if (pool.kind !== 'ready')
69
69
  return {};
70
70
  const now = Date.now();
@@ -75,7 +75,12 @@ async function poolUsageNotice(pool, store) {
75
75
  const ttl = usage?.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS;
76
76
  const windows = usage && usage.observedAt <= now && now < usage.observedAt + ttl
77
77
  ? usage.windows.filter(w => w.resetAt === undefined || w.resetAt > now) : [];
78
- const rejected = state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false;
78
+ const rejection = selectSubscriptionAccount({
79
+ pool: { kind: 'ready', accounts: [account] },
80
+ states: { [account.accountId]: { usage: usage ? { ...usage, windows: usage.windows.filter(w => w.status === 'rejected') } : undefined } },
81
+ model: resolved.model, now,
82
+ });
83
+ const rejected = rejection.kind === 'deferred' || (state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false);
79
84
  const blockers = windows.filter(w => w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT));
80
85
  const resets = blockers.map(w => w.resetAt);
81
86
  return {
@@ -94,9 +99,9 @@ async function poolUsageNotice(pool, store) {
94
99
  return {};
95
100
  }
96
101
  }
97
- /** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
102
+ /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
98
103
  export async function poolDeferredError(pool, resolved, store, eligibleAt, log) {
99
- const notice = await poolUsageNotice(pool, store);
104
+ const notice = await poolUsageNotice(pool, resolved, store);
100
105
  let usageDeferred = eligibleAt !== undefined;
101
106
  const deferredError = (resetAt) => {
102
107
  const error = new PoolDeferredError(eligibleAt, resetAt);
@@ -115,6 +120,9 @@ export async function poolDeferredError(pool, resolved, store, eligibleAt, log)
115
120
  if (selected.kind !== 'deferred' || selected.eligibleAt !== eligibleAt)
116
121
  continue;
117
122
  const state = await store.read(account);
123
+ const usage = state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined;
124
+ if (usage?.windows.some(w => w.status === 'rejected' && w.resetAt === eligibleAt))
125
+ return deferredError(new Date(eligibleAt).toISOString());
118
126
  const live = state.reactive.filter(r => !r.quota.resetAt || Date.parse(r.quota.resetAt) > Date.now());
119
127
  if (live.length && live.every(r => r.quota.resetAt && Number.isFinite(Date.parse(r.quota.resetAt))) && live.some(r => Date.parse(r.quota.resetAt) === eligibleAt))
120
128
  return deferredError(new Date(eligibleAt).toISOString());
@@ -1,9 +1,12 @@
1
+ import type { AccountProbeFailureReason } from './usage-probe-contract.js';
1
2
  import type { ClaudeQuotaFailure } from './claude-quota.js';
2
3
  export declare const USAGE_THRESHOLD_PERCENT = 90;
3
4
  /** Passive snapshots are useful for five minutes, then become unknown. */
4
5
  export declare const PASSIVE_USAGE_TTL_MS: number;
5
6
  export declare const PROBE_USAGE_TTL_MS = 60000;
6
7
  export declare const POOL_RECHECK_MS: number;
8
+ /** Safety hold from the rejection observation when the provider gives no reset. */
9
+ export declare const REJECTED_BLOCK_MS: number;
7
10
  export interface AccountIdentity {
8
11
  workspaceId: string;
9
12
  accountId: string;
@@ -14,6 +17,9 @@ export interface AccountIdentity {
14
17
  export type PoolFetchResult<A extends AccountIdentity = AccountIdentity> = {
15
18
  kind: 'ready';
16
19
  accounts: readonly A[];
20
+ } | {
21
+ kind: 'probe_account_unavailable';
22
+ reason: AccountProbeFailureReason;
17
23
  } | {
18
24
  kind: 'no_accounts_configured';
19
25
  } | {
@@ -30,6 +36,9 @@ export interface UsageWindow {
30
36
  window: 'five_hour' | 'seven_day';
31
37
  scope: 'global' | 'sonnet' | 'opus' | 'haiku' | 'fable' | 'uncertain';
32
38
  status?: 'allowed' | 'allowed_warning' | 'rejected';
39
+ /** Retained rejections keep their own clock when merged into newer measurements. */
40
+ observedAt?: number;
41
+ source?: 'passive' | 'probe';
33
42
  percent?: number;
34
43
  resetAt?: number;
35
44
  }
@@ -38,6 +47,8 @@ export interface UsageObservation {
38
47
  observedAt: number;
39
48
  windows: readonly UsageWindow[];
40
49
  }
50
+ /** Latest measurements plus durable rejections; omission or success never clears a hold. */
51
+ export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
41
52
  export interface ReactiveExhaustion {
42
53
  observedAt: number;
43
54
  quota: ClaudeQuotaFailure;
@@ -46,13 +57,17 @@ export interface AccountUsageState {
46
57
  usage?: UsageObservation;
47
58
  reactive?: readonly ReactiveExhaustion[];
48
59
  }
49
- export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = {
60
+ export interface HeadroomScore {
61
+ score: number;
62
+ rateWindow: UsageWindow;
63
+ reason: 'headroom_per_hour';
64
+ }
65
+ export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = ({
50
66
  kind: 'selected';
51
67
  account: A;
52
68
  provenance: 'known';
53
- score: number;
54
69
  source: 'passive' | 'probe';
55
- } | {
70
+ } & HeadroomScore) | {
56
71
  kind: 'selected';
57
72
  account: A;
58
73
  provenance: 'unknown';
@@ -73,5 +88,7 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
73
88
  /** Stable account ID within the pool's single workspace. */
74
89
  states: Readonly<Record<string, AccountUsageState | undefined>>;
75
90
  model: string | undefined;
91
+ /** Tie preference only; session pins are enforced separately by the runtime. */
92
+ currentAccountId?: string;
76
93
  now: number;
77
94
  }): SelectionOutcome<A>;
@@ -3,6 +3,40 @@ export const USAGE_THRESHOLD_PERCENT = 90;
3
3
  export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
4
4
  export const PROBE_USAGE_TTL_MS = 60_000;
5
5
  export const POOL_RECHECK_MS = 15 * 60_000;
6
+ /** Safety hold from the rejection observation when the provider gives no reset. */
7
+ export const REJECTED_BLOCK_MS = 15 * 60_000;
8
+ /** Latest measurements plus durable rejections; omission or success never clears a hold. */
9
+ export function mergeUsageObservations(observations, now) {
10
+ let latest;
11
+ const rejected = new Map();
12
+ for (const usage of observations) {
13
+ if (!usage || usage.observedAt > now)
14
+ continue;
15
+ if (!latest || usage.observedAt >= latest.observedAt)
16
+ latest = usage;
17
+ for (const w of usage.windows) {
18
+ const observedAt = w.observedAt ?? usage.observedAt;
19
+ if (w.status !== 'rejected' || observedAt > now)
20
+ continue;
21
+ const key = `${w.window}:${w.scope}`;
22
+ const previous = rejected.get(key);
23
+ if (!previous || observedAt >= previous.observedAt)
24
+ rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
25
+ }
26
+ }
27
+ if (!latest)
28
+ return undefined;
29
+ const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
30
+ return {
31
+ ...latest,
32
+ windows: [
33
+ ...latest.windows.filter(w => w.status !== 'rejected' &&
34
+ (w.observedAt ?? latest.observedAt) === latest.observedAt &&
35
+ (w.source ?? latest.source) === latest.source).slice(0, 133 - active.length),
36
+ ...active,
37
+ ],
38
+ };
39
+ }
6
40
  export function record(value) {
7
41
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
42
  }
@@ -92,7 +126,7 @@ export function normalizeProbeUsage(value, observedAt) {
92
126
  return { source: 'probe', observedAt, windows };
93
127
  }
94
128
  function family(model) {
95
- return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
129
+ return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku|fable)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
96
130
  }
97
131
  function applicable(scope, model) {
98
132
  return scope === 'global' || scope === 'uncertain' || family(model) === undefined || scope === family(model);
@@ -101,6 +135,21 @@ export function reactiveEligibleAt(reactive) {
101
135
  const parsed = reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : NaN;
102
136
  return Number.isFinite(parsed) && parsed > reactive.observedAt ? parsed : reactive.observedAt + POOL_RECHECK_MS;
103
137
  }
138
+ /** Called only for nonempty, applicable known windows after all blockers are ruled out.
139
+ * The minimum rate is the account's binding window; retain its numbers for explanation.
140
+ */
141
+ function scoreHeadroom(windows, threshold, now) {
142
+ let score = Infinity;
143
+ let rateWindow = windows[0];
144
+ for (const window of windows) {
145
+ const rate = (threshold - window.percent) / Math.max((window.resetAt - now) / 3600_000, 0.25);
146
+ if (rate < score) {
147
+ score = rate;
148
+ rateWindow = window;
149
+ }
150
+ }
151
+ return { score, rateWindow, reason: 'headroom_per_hour' };
152
+ }
104
153
  /** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
105
154
  export function selectSubscriptionAccount(input) {
106
155
  if (input.pool.kind !== 'ready')
@@ -128,9 +177,22 @@ export function selectSubscriptionAccount(input) {
128
177
  blockers.push(until);
129
178
  }
130
179
  const usage = state?.usage;
180
+ // A provider rejection outlives snapshot freshness. Never score it as headroom.
181
+ if (usage && usage.observedAt <= input.now) {
182
+ for (const w of usage.windows) {
183
+ if (w.status !== 'rejected' || !applicable(w.scope, input.model))
184
+ continue;
185
+ const observedAt = w.observedAt ?? usage.observedAt;
186
+ if (observedAt > input.now)
187
+ continue;
188
+ const until = w.resetAt ?? observedAt + REJECTED_BLOCK_MS;
189
+ if (input.now < until)
190
+ blockers.push(until);
191
+ }
192
+ }
131
193
  const expiry = usage ? usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS) : 0;
132
194
  const fresh = usage && usage.observedAt <= input.now && input.now < expiry;
133
- const windows = fresh ? usage.windows.filter(w => applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
195
+ const windows = fresh ? usage.windows.filter(w => w.status !== 'rejected' && (w.observedAt ?? usage.observedAt) === usage.observedAt && (w.source ?? usage.source) === usage.source && applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
134
196
  const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
135
197
  for (const w of windows) {
136
198
  if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
@@ -140,14 +202,14 @@ export function selectSubscriptionAccount(input) {
140
202
  deadlines.push(Math.max(...blockers));
141
203
  }
142
204
  else if (windows.length && !uncertain && windows.every(w => w.percent !== undefined && w.resetAt !== undefined)) {
143
- known.push({ account, score: Math.max(...windows.map(w => w.percent)), source: usage.source });
205
+ known.push({ account, ...scoreHeadroom(windows, account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT, input.now), source: usage.source });
144
206
  }
145
207
  else {
146
208
  unknown.push({ account, uncertainApplicability: uncertain });
147
209
  }
148
210
  }
149
211
  const compare = (a, b) => a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0;
150
- known.sort((a, b) => a.score - b.score || compare(a.account, b.account));
212
+ known.sort((a, b) => b.score - a.score || Number(b.account.accountId === input.currentAccountId) - Number(a.account.accountId === input.currentAccountId) || compare(a.account, b.account));
151
213
  unknown.sort((a, b) => compare(a.account, b.account));
152
214
  if (known[0])
153
215
  return { kind: 'selected', provenance: 'known', ...known[0] };
@@ -9,6 +9,8 @@ export interface ProbeObservation {
9
9
  usage?: UsageObservation;
10
10
  }
11
11
  export interface StoredAccountUsage {
12
+ /** Latest measured snapshot plus active rejected windows, persisted across observations. */
13
+ usage?: UsageObservation;
12
14
  passive?: {
13
15
  rateLimitInfo: ClaudeRateLimitInfo;
14
16
  observedAt: number;
@@ -2,12 +2,16 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
5
- import { normalizePassiveUsage, record, reactiveEligibleAt, PASSIVE_USAGE_TTL_MS } from './subscription-selection.js';
5
+ import { normalizePassiveUsage, mergeUsageObservations, record, reactiveEligibleAt } from './subscription-selection.js';
6
6
  export function usageStateForSelection(state, now) {
7
- const passive = state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined;
8
- // Passive is primary while fresh; a completed optional probe may supplement a stale/missing snapshot.
9
- const fresh = passive && passive.observedAt <= now && now < passive.observedAt + PASSIVE_USAGE_TTL_MS;
10
- return { usage: fresh ? passive : state.probe?.status === 'ok' ? state.probe.usage : passive, reactive: state.reactive };
7
+ return {
8
+ usage: mergeUsageObservations([
9
+ state.usage,
10
+ state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined,
11
+ state.probe?.status === 'ok' ? state.probe.usage : undefined,
12
+ ], now),
13
+ reactive: state.reactive,
14
+ };
11
15
  }
12
16
  export class SubscriptionUsageStoreError extends Error {
13
17
  constructor() { super('Subscription usage store unavailable'); this.name = 'SubscriptionUsageStoreError'; }
@@ -51,6 +55,28 @@ export function safeQuota(quota) {
51
55
  ...(Number.isFinite(reset) && reset > 0 ? { resetAt: new Date(reset).toISOString() } : {}),
52
56
  };
53
57
  }
58
+ function safeUsage(value, source) {
59
+ const usage = record(value);
60
+ if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
61
+ throw new SubscriptionUsageStoreError();
62
+ validTime(usage['observedAt']);
63
+ const windows = usage['windows'].slice(0, 133).map(raw => {
64
+ const w = record(raw);
65
+ const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
66
+ const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
67
+ const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
68
+ const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
69
+ const status = w['status'] === 'allowed' || w['status'] === 'allowed_warning' || w['status'] === 'rejected' ? w['status'] : undefined;
70
+ const observedAt = typeof w['observedAt'] === 'number' ? w['observedAt'] : undefined;
71
+ if (observedAt !== undefined)
72
+ validTime(observedAt);
73
+ const source = w['source'] === 'passive' || w['source'] === 'probe' ? w['source'] : undefined;
74
+ return { window, scope, percent, resetAt, ...(status ? { status } : {}), ...(observedAt ? { observedAt } : {}), ...(source ? { source } : {}) };
75
+ });
76
+ if (usage['windows'].length > 133)
77
+ windows.push({ window: 'seven_day', scope: 'uncertain' });
78
+ return { source, observedAt: usage['observedAt'], windows };
79
+ }
54
80
  function safeProbe(value) {
55
81
  const obj = record(value);
56
82
  const status = obj['status'];
@@ -62,21 +88,7 @@ function safeProbe(value) {
62
88
  validTime(observedAt);
63
89
  const result = { status: status, observedAt };
64
90
  if (status === 'ok' && obj['usage']) {
65
- const usage = record(obj['usage']);
66
- if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
67
- throw new SubscriptionUsageStoreError();
68
- validTime(usage['observedAt']);
69
- const windows = usage['windows'].slice(0, 133).map(raw => {
70
- const w = record(raw);
71
- const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
72
- const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
73
- const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
74
- const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
75
- return { window, scope, percent, resetAt };
76
- });
77
- if (usage['windows'].length > 133)
78
- windows.push({ window: 'seven_day', scope: 'uncertain' });
79
- result.usage = { source: 'probe', observedAt: usage['observedAt'], windows };
91
+ result.usage = safeUsage(obj['usage'], 'probe');
80
92
  }
81
93
  return result;
82
94
  }
@@ -88,6 +100,9 @@ function safeVersion(value) {
88
100
  }
89
101
  if (value.probe)
90
102
  state.probe = safeProbe(value.probe);
103
+ if (value.usage) {
104
+ state.usage = safeUsage(value.usage, value.usage.source === 'passive' ? 'passive' : 'probe');
105
+ }
91
106
  return state;
92
107
  }
93
108
  function safeReactive(value) {
@@ -158,7 +173,9 @@ export class FileSubscriptionUsageStore {
158
173
  const state = safeVersion(await this.#load(path, {}));
159
174
  if (state.passive && state.passive.observedAt > observedAt && !options?.expireFutureObservations)
160
175
  return;
176
+ const previous = usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
161
177
  state.passive = { rateLimitInfo: safeRateLimitInfo(rateLimitInfo), observedAt };
178
+ state.usage = mergeUsageObservations([previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt)], observedAt);
162
179
  await this.#save(path, state);
163
180
  });
164
181
  }
@@ -216,7 +233,11 @@ export class FileSubscriptionUsageStore {
216
233
  const path = this.#path(key, true);
217
234
  await serial(path, async () => {
218
235
  const state = safeVersion(await this.#load(path, {}));
236
+ // Network completion order must not discard a newer stored snapshot.
237
+ const mergeAt = Math.max(observation.observedAt, state.usage?.observedAt ?? 0, state.passive?.observedAt ?? 0);
238
+ const previous = usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
219
239
  state.probe = safeProbe(observation);
240
+ state.usage = mergeUsageObservations([previous, state.probe.usage], mergeAt);
220
241
  await this.#save(path, state);
221
242
  });
222
243
  }
@@ -0,0 +1,23 @@
1
+ export interface ThreadRouting {
2
+ workspaceId: string;
3
+ channelId: string;
4
+ brainId?: string;
5
+ /** Historical evidence only. Recovery always derives the current directory. */
6
+ directory: string;
7
+ threadId?: string;
8
+ processedDropIds?: string[];
9
+ }
10
+ /** Poller state survives inbox pruning and process restarts; contains no credentials. */
11
+ export declare function saveThreadRouting(threadId: string, routing: ThreadRouting): void;
12
+ export declare function readThreadRouting(threadId: string): ThreadRouting | undefined;
13
+ export declare function inboxDropId(threadId: string, filename: string, content: string | Uint8Array): string;
14
+ export declare function recordProcessedDrop(threadId: string, filename: string, content: string | Uint8Array): void;
15
+ export interface StoredThreadRouting {
16
+ key: string;
17
+ threadId?: string;
18
+ modifiedAt: number;
19
+ }
20
+ /** Older records have no threadId; discovery can match their hashed file name. */
21
+ export declare function listThreadRoutings(): StoredThreadRouting[];
22
+ export declare function threadRoutingKey(threadId: string): string;
23
+ export declare function removeThreadRouting(key: string): void;
@@ -0,0 +1,81 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { config } from './config.js';
5
+ function routingPath(threadId) {
6
+ return join(config.TEAMVIBE_DATA_DIR, 'thread-routing', threadRoutingKey(threadId));
7
+ }
8
+ /** Poller state survives inbox pruning and process restarts; contains no credentials. */
9
+ export function saveThreadRouting(threadId, routing) {
10
+ mkdirSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing'), { recursive: true, mode: 0o700 });
11
+ const path = routingPath(threadId);
12
+ const temporary = `${path}.${randomUUID()}.tmp`;
13
+ try {
14
+ writeFileSync(temporary, JSON.stringify({ ...readThreadRouting(threadId), ...routing, threadId, updatedAt: Date.now() }), { mode: 0o600 });
15
+ renameSync(temporary, path);
16
+ }
17
+ finally {
18
+ rmSync(temporary, { force: true });
19
+ }
20
+ }
21
+ export function readThreadRouting(threadId) {
22
+ try {
23
+ const routing = JSON.parse(readFileSync(routingPath(threadId), 'utf8'));
24
+ if (typeof routing.workspaceId !== 'string' || typeof routing.channelId !== 'string' || typeof routing.directory !== 'string' || routing.brainId !== undefined && typeof routing.brainId !== 'string')
25
+ return undefined;
26
+ if (routing.processedDropIds !== undefined && (!Array.isArray(routing.processedDropIds) || !routing.processedDropIds.every((id) => typeof id === 'string')))
27
+ return undefined;
28
+ return routing;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export function inboxDropId(threadId, filename, content) {
35
+ return createHash('sha256').update(JSON.stringify([threadId, filename, createHash('sha256').update(content).digest('hex')])).digest('hex');
36
+ }
37
+ export function recordProcessedDrop(threadId, filename, content) {
38
+ const routing = readThreadRouting(threadId);
39
+ if (!routing)
40
+ return;
41
+ const id = inboxDropId(threadId, filename, content);
42
+ saveThreadRouting(threadId, { ...routing, processedDropIds: [...new Set([...(routing.processedDropIds ?? []), id])] });
43
+ }
44
+ /** Older records have no threadId; discovery can match their hashed file name. */
45
+ export function listThreadRoutings() {
46
+ const root = join(config.TEAMVIBE_DATA_DIR, 'thread-routing');
47
+ let names;
48
+ try {
49
+ names = readdirSync(root);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ return names.filter(name => /^[a-f0-9]{64}\.json$/.test(name)).flatMap(name => {
55
+ try {
56
+ const path = join(root, name);
57
+ let modifiedAt = statSync(path).mtimeMs;
58
+ let threadId;
59
+ try {
60
+ const value = JSON.parse(readFileSync(path, 'utf8'));
61
+ if (typeof value.updatedAt === 'number' && Number.isFinite(value.updatedAt))
62
+ modifiedAt = value.updatedAt;
63
+ if (typeof value.threadId === 'string' && routingPath(value.threadId) === path)
64
+ threadId = value.threadId;
65
+ }
66
+ catch { /* Corrupt records still expire; match any drops by the hash. */ }
67
+ return [{ key: name, threadId, modifiedAt }];
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ });
73
+ }
74
+ export function threadRoutingKey(threadId) {
75
+ return `${createHash('sha256').update(threadId).digest('hex')}.json`;
76
+ }
77
+ export function removeThreadRouting(key) {
78
+ if (!/^[a-f0-9]{64}\.json$/.test(key))
79
+ throw new Error('Invalid routing key');
80
+ rmSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing', key), { force: true });
81
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { UsageProbeMessage } from './usage-probe-contract.js';
2
+ export type { UsageProbeMessage, UsageProbeFailureReason } from './usage-probe-contract.js';
1
3
  export interface ControlMessage {
2
4
  id: string;
3
5
  source: 'control';
@@ -69,6 +71,7 @@ export interface TeamVibeQueueMessage {
69
71
  export interface SubscriptionPin {
70
72
  inboxFilename?: string;
71
73
  resumePending?: boolean;
74
+ /** Comparison evidence only; replay derives execution paths from current routing. */
72
75
  effectiveCwd?: string;
73
76
  claudeConfigDir?: string;
74
77
  sawSendMessageCall?: boolean;
@@ -92,7 +95,7 @@ export interface SessionRecord {
92
95
  last_message_ts?: string;
93
96
  ttl: number;
94
97
  }
95
- export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input';
98
+ export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input' | 'completed' | 'failed' | 'aborted';
96
99
  export interface SessionMetrics {
97
100
  /** Input occupancy of the last assistant message, independent of run totals. */
98
101
  context_tokens?: number;
@@ -107,12 +110,3 @@ export interface SessionMetrics {
107
110
  cache_creation_input_tokens?: number;
108
111
  model?: string;
109
112
  }
110
- export interface UsageProbeMessage {
111
- kind: 'usage_probe';
112
- id: string;
113
- workspaceId: string;
114
- accountId: string;
115
- credentialVersion: string;
116
- requestId: string;
117
- expiresAt: number;
118
- }
@@ -1,5 +1,5 @@
1
1
  /** Owner-facing account policy location. */
2
- export declare const FULL_QUOTA_SETTINGS_PATH = "Settings \u2192 Connections (account menu \u2192 Use until exhausted)";
2
+ export declare const FULL_QUOTA_SETTINGS_PATH = "Connections (account menu \u2192 Use until exhausted)";
3
3
  export interface UsageResetTiming {
4
4
  resetAt?: number;
5
5
  now?: number;
@@ -1,5 +1,5 @@
1
1
  /** Owner-facing account policy location. */
2
- export const FULL_QUOTA_SETTINGS_PATH = 'Settings → Connections (account menu → Use until exhausted)';
2
+ export const FULL_QUOTA_SETTINGS_PATH = 'Connections (account menu → Use until exhausted)';
3
3
  export function formatUsageReset(input) {
4
4
  if (input.resetAt === undefined || !Number.isFinite(input.resetAt))
5
5
  return undefined;
@@ -0,0 +1,15 @@
1
+ /** Shared wire contract for request-scoped usage probes. */
2
+ export declare const ACCOUNT_PROBE_FAILURE_REASONS: readonly ["account_changed", "account_disabled", "account_missing"];
3
+ export declare const USAGE_PROBE_FAILURE_REASONS: readonly ["account_changed", "account_disabled", "account_missing", "token_rejected", "cli_at_capacity", "timeout", "unknown"];
4
+ export type AccountProbeFailureReason = (typeof ACCOUNT_PROBE_FAILURE_REASONS)[number];
5
+ export type UsageProbeFailureReason = (typeof USAGE_PROBE_FAILURE_REASONS)[number];
6
+ export declare const USAGE_UPDATE_TTL_MS: number;
7
+ export interface UsageProbeMessage {
8
+ kind: 'usage_probe';
9
+ id: string;
10
+ workspaceId: string;
11
+ accountId: string;
12
+ credentialVersion: string;
13
+ requestId: string;
14
+ expiresAt: number;
15
+ }
@@ -0,0 +1,14 @@
1
+ /** Shared wire contract for request-scoped usage probes. */
2
+ export const ACCOUNT_PROBE_FAILURE_REASONS = [
3
+ 'account_changed',
4
+ 'account_disabled',
5
+ 'account_missing',
6
+ ];
7
+ export const USAGE_PROBE_FAILURE_REASONS = [
8
+ ...ACCOUNT_PROBE_FAILURE_REASONS,
9
+ 'token_rejected',
10
+ 'cli_at_capacity',
11
+ 'timeout',
12
+ 'unknown',
13
+ ];
14
+ export const USAGE_UPDATE_TTL_MS = 10 * 60_000;