@teamvibe/poller 0.8.2-rc.1 → 0.8.3-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 (43) hide show
  1. package/dist/classify-failed-iteration.d.ts +7 -0
  2. package/dist/classify-failed-iteration.js +8 -0
  3. package/dist/claude-quota.d.ts +15 -0
  4. package/dist/claude-quota.js +188 -0
  5. package/dist/claude-rate-limit.d.ts +19 -0
  6. package/dist/claude-rate-limit.js +44 -0
  7. package/dist/claude-spawner.d.ts +15 -0
  8. package/dist/claude-spawner.js +307 -26
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/config.d.ts +4 -0
  12. package/dist/config.js +1 -0
  13. package/dist/inbox-manager.d.ts +22 -1
  14. package/dist/inbox-manager.js +57 -3
  15. package/dist/inbox-watcher.d.ts +1 -1
  16. package/dist/inbox-watcher.js +42 -20
  17. package/dist/poller.js +262 -51
  18. package/dist/pool-defer.d.ts +13 -0
  19. package/dist/pool-defer.js +81 -0
  20. package/dist/pool-telemetry.d.ts +44 -0
  21. package/dist/pool-telemetry.js +107 -0
  22. package/dist/quota-pause.d.ts +16 -0
  23. package/dist/quota-pause.js +96 -0
  24. package/dist/session-store.d.ts +2 -2
  25. package/dist/session-store.js +6 -3
  26. package/dist/slack-client.d.ts +4 -1
  27. package/dist/slack-client.js +100 -11
  28. package/dist/subscription-pool-client.d.ts +32 -0
  29. package/dist/subscription-pool-client.js +139 -0
  30. package/dist/subscription-pool.d.ts +18 -0
  31. package/dist/subscription-pool.js +24 -0
  32. package/dist/subscription-runtime.d.ts +53 -0
  33. package/dist/subscription-runtime.js +74 -0
  34. package/dist/subscription-selection.d.ts +75 -0
  35. package/dist/subscription-selection.js +142 -0
  36. package/dist/subscription-usage-probe.d.ts +16 -0
  37. package/dist/subscription-usage-probe.js +106 -0
  38. package/dist/subscription-usage-store.d.ts +47 -0
  39. package/dist/subscription-usage-store.js +206 -0
  40. package/dist/types.d.ts +25 -9
  41. package/dist/worktree-manager.js +4 -0
  42. package/npm-shrinkwrap.json +78 -77
  43. package/package.json +1 -1
@@ -0,0 +1,107 @@
1
+ import { emitEvent } from './event-queue.js';
2
+ import { normalizePassiveUsage, reactiveEligibleAt, } from './subscription-selection.js';
3
+ import { safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
4
+ function safeText(value) {
5
+ return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
6
+ ? value
7
+ : undefined;
8
+ }
9
+ function safeMetrics(metrics) {
10
+ if (!metrics)
11
+ return undefined;
12
+ const result = {};
13
+ for (const key of [
14
+ 'total_cost_usd',
15
+ 'input_tokens',
16
+ 'output_tokens',
17
+ 'cache_read_input_tokens',
18
+ 'cache_creation_input_tokens',
19
+ 'duration_ms',
20
+ 'duration_api_ms',
21
+ 'num_turns',
22
+ ]) {
23
+ const value = metrics[key];
24
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
25
+ result[key] = value;
26
+ }
27
+ const model = safeText(metrics.model);
28
+ if (model)
29
+ result.model = model;
30
+ return result;
31
+ }
32
+ /** Explicit transport DTO: never serialize a pool account/credential envelope. */
33
+ export function accountUsageTelemetry(account, state) {
34
+ const passive = state.passive
35
+ ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt)
36
+ : undefined;
37
+ const probe = state.probe?.usage;
38
+ const usage = passive && (!probe || passive.observedAt >= probe.observedAt)
39
+ ? passive
40
+ : probe;
41
+ return {
42
+ accountId: account.accountId,
43
+ accountLabel: account.label ?? account.accountId,
44
+ reportedAt: Date.now(),
45
+ ...(usage ? { usage } : {}),
46
+ ...(state.probe
47
+ ? {
48
+ probeStatus: state.probe.status,
49
+ probeObservedAt: state.probe.observedAt,
50
+ }
51
+ : {}),
52
+ ...(state.passive
53
+ ? { lastRateLimitInfo: state.passive.rateLimitInfo }
54
+ : {}),
55
+ reactive: state.reactive.map((r) => ({
56
+ observedAt: r.observedAt,
57
+ window: r.quota.window,
58
+ scope: r.quota.modelScope ?? 'global',
59
+ resetAt: r.quota.resetAt ? Date.parse(r.quota.resetAt) : undefined,
60
+ eligibleAt: reactiveEligibleAt(r),
61
+ })),
62
+ };
63
+ }
64
+ export async function emitCandidateUsage(msg, accounts, store) {
65
+ // Usage storage is already local. Observability failures never change selection.
66
+ for (const account of accounts) {
67
+ try {
68
+ emitEvent('pool.usage_observed', msg.teamvibe.channelId, {
69
+ workspaceId: msg.teamvibe.workspaceId,
70
+ ...accountUsageTelemetry(account, await store.read(account)),
71
+ });
72
+ }
73
+ catch { }
74
+ }
75
+ }
76
+ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
77
+ const safeAttempts = attempts.map((a) => ({
78
+ accountId: a.accountId,
79
+ accountLabel: safeText(a.accountLabel) ?? a.accountId,
80
+ sessionId: safeText(a.sessionId),
81
+ model: safeText(a.model),
82
+ success: a.success,
83
+ exitCode: a.exitCode,
84
+ observedAt: a.observedAt,
85
+ quota: a.quota ? safeQuota(a.quota) : undefined,
86
+ metrics: safeMetrics(a.metrics),
87
+ lastRateLimitInfo: a.rateLimitInfo
88
+ ? safeRateLimitInfo(a.rateLimitInfo)
89
+ : undefined,
90
+ }));
91
+ const last = safeAttempts.at(-1);
92
+ return {
93
+ ...(last
94
+ ? {
95
+ accountId: last.accountId,
96
+ accountLabel: last.accountLabel,
97
+ model: last.model,
98
+ observedAt: last.observedAt,
99
+ lastRateLimitInfo: last.lastRateLimitInfo,
100
+ }
101
+ : {}),
102
+ chainLength: attempts.length,
103
+ switchedAccounts: new Set(attempts.map((a) => a.accountId)).size > 1,
104
+ quotaDeferEpisodes,
105
+ attempts: safeAttempts,
106
+ };
107
+ }
@@ -0,0 +1,16 @@
1
+ import type { ClaudeQuotaFailure } from './claude-quota.js';
2
+ export declare const UNKNOWN_QUOTA_RECHECK_MS: number;
3
+ export declare class QuotaPausedError extends Error {
4
+ constructor();
5
+ }
6
+ export interface QuotaPause {
7
+ createdAt: number;
8
+ resumeAt: number;
9
+ brainPath: string;
10
+ threadId: string;
11
+ }
12
+ export declare function readQuotaPause(): QuotaPause | undefined;
13
+ export declare function isQuotaPaused(): boolean;
14
+ /** Persist before notification; only the first quota result owns the notice. */
15
+ export declare function pauseForQuota(quota: ClaudeQuotaFailure, brainPath: string, threadId: string): boolean;
16
+ export declare function clearQuotaPauseAfterSuccess(): void;
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { config } from './config.js';
4
+ import { logger } from './logger.js';
5
+ export const UNKNOWN_QUOTA_RECHECK_MS = 15 * 60_000;
6
+ export class QuotaPausedError extends Error {
7
+ constructor() {
8
+ super('Claude account is paused for quota');
9
+ }
10
+ }
11
+ // The ambient credential path retains the original poller-wide pause.
12
+ // Configured subscription pools use per-account usage and per-thread pool-defer.
13
+ function markerPath() {
14
+ return join(config.TEAMVIBE_DATA_DIR, 'quota-pause.json');
15
+ }
16
+ const unreadableMarkers = new Set();
17
+ const cleanupFailures = new Set();
18
+ export function readQuotaPause() {
19
+ const path = markerPath();
20
+ try {
21
+ const pause = JSON.parse(readFileSync(path, 'utf8'));
22
+ if (pause && Number.isFinite(pause.createdAt) && Number.isFinite(pause.resumeAt)
23
+ && Number.isFinite(pause.resumeAt - pause.createdAt) && pause.resumeAt > pause.createdAt
24
+ && typeof pause.brainPath === 'string' && typeof pause.threadId === 'string') {
25
+ unreadableMarkers.delete(path);
26
+ return pause;
27
+ }
28
+ throw new Error('Invalid quota pause marker');
29
+ }
30
+ catch (error) {
31
+ if (error.code === 'ENOENT') {
32
+ unreadableMarkers.delete(path);
33
+ return undefined;
34
+ }
35
+ if (!unreadableMarkers.has(path)) {
36
+ unreadableMarkers.add(path);
37
+ logger.warn('[QuotaPause] Unreadable marker treated as expired', error instanceof Error ? error.message : String(error));
38
+ }
39
+ try {
40
+ rmSync(path, { recursive: true, force: true });
41
+ }
42
+ catch {
43
+ // A directory or unreadable file can still be moved out of the way.
44
+ // Cleanup failure must never stop the watcher from resuming saved work.
45
+ try {
46
+ renameSync(path, `${path}.invalid`);
47
+ }
48
+ catch { }
49
+ }
50
+ return undefined;
51
+ }
52
+ }
53
+ export function isQuotaPaused() {
54
+ const pause = readQuotaPause();
55
+ if (!pause)
56
+ return false;
57
+ const now = Date.now();
58
+ // A backward clock correction must not extend the original pause duration.
59
+ const bound = pause.resumeAt - pause.createdAt;
60
+ return now >= pause.createdAt && now < pause.resumeAt && pause.resumeAt - now <= bound;
61
+ }
62
+ /** Persist before notification; only the first quota result owns the notice. */
63
+ export function pauseForQuota(quota, brainPath, threadId) {
64
+ if (isQuotaPaused())
65
+ return false;
66
+ const now = Date.now();
67
+ const reset = quota.resetAt ? Date.parse(quota.resetAt) : NaN;
68
+ const pause = {
69
+ createdAt: now,
70
+ resumeAt: Number.isFinite(reset) && reset > now ? reset : now + UNKNOWN_QUOTA_RECHECK_MS,
71
+ brainPath,
72
+ threadId,
73
+ };
74
+ mkdirSync(config.TEAMVIBE_DATA_DIR, { recursive: true });
75
+ const path = markerPath();
76
+ writeFileSync(`${path}.tmp`, JSON.stringify(pause), { mode: 0o600 });
77
+ renameSync(`${path}.tmp`, path);
78
+ return true;
79
+ }
80
+ export function clearQuotaPauseAfterSuccess() {
81
+ // An already-running request may succeed after another thread exhausted the
82
+ // account. Its success must not erase that thread's newly active pause.
83
+ const path = markerPath();
84
+ try {
85
+ if (!isQuotaPaused()) {
86
+ rmSync(path, { recursive: true, force: true });
87
+ cleanupFailures.delete(path);
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (!unreadableMarkers.has(path) && !cleanupFailures.has(path)) {
92
+ cleanupFailures.add(path);
93
+ logger.warn('[QuotaPause] Marker cleanup failed after success', error instanceof Error ? error.message : String(error));
94
+ }
95
+ }
96
+ }
@@ -1,4 +1,4 @@
1
- import type { SessionRecord, SessionStatus } from './types.js';
1
+ import type { SessionRecord, SessionStatus, SubscriptionPin } from './types.js';
2
2
  export declare function getSession(threadId: string): Promise<SessionRecord | null>;
3
3
  export declare function createSession(threadId: string, workspacePath: string): Promise<SessionRecord>;
4
4
  export interface AcquireLockResult {
@@ -7,6 +7,6 @@ export interface AcquireLockResult {
7
7
  lockToken: string | null;
8
8
  }
9
9
  export declare function acquireSessionLock(threadId: string, workspacePath: string): Promise<AcquireLockResult>;
10
- export declare function updateSessionId(threadId: string, lockToken: string, newSessionId: string): Promise<void>;
10
+ export declare function updateSessionId(threadId: string, lockToken: string, newSessionId: string, pin?: SubscriptionPin): Promise<boolean>;
11
11
  export declare function releaseSessionLock(threadId: string, lockToken: string, newStatus?: SessionStatus, lastMessageTs?: string): Promise<void>;
12
12
  export declare function forceReleaseStalelock(threadId: string, maxAgeMs: number): Promise<boolean>;
@@ -43,6 +43,7 @@ function generateLockToken() {
43
43
  export async function getSession(threadId) {
44
44
  const key = buildKey(threadId);
45
45
  const result = await createDocClient().send(new GetCommand({
46
+ ConsistentRead: true,
46
47
  TableName: getTableName(),
47
48
  Key: key,
48
49
  }));
@@ -149,25 +150,27 @@ export async function acquireSessionLock(threadId, workspacePath) {
149
150
  throw error;
150
151
  }
151
152
  }
152
- export async function updateSessionId(threadId, lockToken, newSessionId) {
153
+ export async function updateSessionId(threadId, lockToken, newSessionId, pin) {
153
154
  try {
154
155
  await createDocClient().send(new UpdateCommand({
155
156
  TableName: getTableName(),
156
157
  Key: buildKey(threadId),
157
- UpdateExpression: 'SET session_id = :newSessionId',
158
+ UpdateExpression: pin ? 'SET session_id = :newSessionId, subscription_pin = :pin' : 'SET session_id = :newSessionId',
158
159
  ConditionExpression: 'lock_token = :lockToken',
159
160
  ExpressionAttributeValues: {
160
161
  ':newSessionId': newSessionId,
162
+ ...(pin ? { ':pin': Object.fromEntries(Object.entries(pin).filter(([, value]) => value !== undefined)) } : {}),
161
163
  ':lockToken': lockToken,
162
164
  },
163
165
  }));
164
166
  logger.info(`Updated session_id for thread ${threadId} to ${newSessionId}`);
167
+ return true;
165
168
  }
166
169
  catch (error) {
167
170
  if (error.name === 'ConditionalCheckFailedException') {
168
171
  logger.warn(`Lock token mismatch when updating session_id for ${threadId}`);
169
172
  emitError('session', `Lock token mismatch when updating session_id for ${threadId}`, { severity: 'warn' });
170
- return;
173
+ return false;
171
174
  }
172
175
  throw error;
173
176
  }
@@ -3,7 +3,10 @@ export declare function getUserInfo(botToken: string, userId: string): Promise<{
3
3
  name: string;
4
4
  realName: string;
5
5
  }>;
6
- export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string): Promise<void>;
6
+ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string, options?: {
7
+ clientMessageId: string;
8
+ reconcile?: boolean;
9
+ }): Promise<void>;
7
10
  export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
8
11
  export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
9
12
  export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
@@ -1,10 +1,13 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
  import { logger } from './logger.js';
3
3
  import { emitError } from './event-queue.js';
4
+ import { config } from './config.js';
4
5
  // Cache user info to avoid repeated API calls
5
6
  const userCache = new Map();
6
7
  // Per-token Slack client cache
7
8
  const slackClients = new Map();
9
+ // Slack: "must be less than 51 characters [json-pointer:/loading_messages/0]"
10
+ const SLACK_LOADING_MESSAGE_MAX = 50;
8
11
  function getSlackClient(botToken) {
9
12
  let client = slackClients.get(botToken);
10
13
  if (!client) {
@@ -42,19 +45,37 @@ export async function getUserInfo(botToken, userId) {
42
45
  userCache.set(userId, fallback);
43
46
  return fallback;
44
47
  }
45
- export async function sendSlackMessage(msg, text) {
48
+ export async function sendSlackMessage(msg, text, options) {
46
49
  if (!msg.response_context.slack) {
47
50
  logger.error('No Slack context in message, cannot respond');
48
51
  return;
49
52
  }
50
53
  const { channel, thread_ts } = msg.response_context.slack;
51
54
  const slack = getSlackClient(msg.teamvibe.botToken);
55
+ if (options?.reconcile) {
56
+ let cursor;
57
+ // A claim may have reached Slack before the process/connection died.
58
+ // Reconcile by stable episode ID; absence or read failure never reposts it.
59
+ for (let page = 0; page < 20; page++) {
60
+ const response = await slack.conversations.replies({ channel, ts: thread_ts, limit: 100, cursor, include_all_metadata: true });
61
+ if (response.messages?.some(message => {
62
+ const payload = message.metadata?.event_payload;
63
+ return ('client_msg_id' in message && message.client_msg_id === options.clientMessageId) || (payload !== null && typeof payload === 'object' && 'episodeId' in payload && payload.episodeId === options.clientMessageId);
64
+ }))
65
+ return;
66
+ cursor = response.response_metadata?.next_cursor;
67
+ if (!cursor)
68
+ break;
69
+ }
70
+ throw new Error('Pool notice delivery remains unresolved');
71
+ }
52
72
  await slack.chat.postMessage({
53
73
  channel,
54
74
  thread_ts,
55
75
  text,
56
76
  unfurl_links: false,
57
77
  unfurl_media: false,
78
+ ...(options ? { client_msg_id: options.clientMessageId, metadata: { event_type: 'subscription_pool_defer', event_payload: { episodeId: options.clientMessageId } } } : {}),
58
79
  });
59
80
  }
60
81
  export async function sendSlackError(msg, error) {
@@ -95,25 +116,59 @@ export async function setThreadStatus(msg, status) {
95
116
  const { channel, thread_ts } = msg.response_context.slack;
96
117
  const slack = getSlackClient(msg.teamvibe.botToken);
97
118
  const safeStatus = neutralizeSlackMarkup(status);
119
+ const payload = {
120
+ channel_id: channel,
121
+ thread_ts,
122
+ status: safeStatus,
123
+ };
98
124
  try {
99
125
  await slack.apiCall('assistant.threads.setStatus', {
100
- channel_id: channel,
101
- thread_ts,
102
- status: safeStatus,
103
- ...(safeStatus ? { loading_messages: [safeStatus] } : {}),
126
+ ...payload,
127
+ ...(safeStatus ? { loading_messages: [truncateLoadingMessage(safeStatus)] } : {}),
104
128
  });
105
129
  }
106
- catch {
130
+ catch (error) {
131
+ const messages = error?.data?.response_metadata?.messages;
132
+ const details = [error?.message, ...(Array.isArray(messages) ? messages : [])].join(' ');
133
+ if (safeStatus && error?.data?.error === 'invalid_arguments' && details.includes('loading_messages')) {
134
+ // API errors may echo request fields; never log the error or status.
135
+ logger.warn('Slack rejected loading messages; retrying thread status');
136
+ try {
137
+ await slack.apiCall('assistant.threads.setStatus', payload);
138
+ return;
139
+ }
140
+ catch {
141
+ emitError('slack', 'Failed to set thread status', { severity: 'warn' });
142
+ return;
143
+ }
144
+ }
107
145
  // API errors may echo request fields; status can contain private narration.
108
146
  logger.warn('Failed to set thread status');
109
147
  emitError('slack', `Failed to set thread status`, { severity: 'warn' });
110
148
  }
111
149
  }
150
+ function truncateLoadingMessage(text) {
151
+ // Treat escaped markup as whole tokens so truncation never leaves a partial entity.
152
+ const tokens = text.match(/&(?:lt|gt|amp);|[\s\S]/gu) ?? [];
153
+ if (text.length <= SLACK_LOADING_MESSAGE_MAX)
154
+ return text;
155
+ let result = '';
156
+ for (const token of tokens) {
157
+ if (result.length + token.length >= SLACK_LOADING_MESSAGE_MAX)
158
+ break;
159
+ result += token;
160
+ }
161
+ return result + '…';
162
+ }
112
163
  function neutralizeSlackMarkup(text) {
113
164
  return text.replace(/[<>&]/g, (character) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[character]);
114
165
  }
115
166
  export function startTypingIndicator(msg, statusText = 'Thinking...') {
116
167
  let currentStatus = statusText;
168
+ let currentSource = statusText === 'Thinking...' ? 'default' : 'comment';
169
+ let commentShownAt = currentSource === 'comment' ? Date.now() : 0;
170
+ let pendingCanned = null;
171
+ let holdTimer;
117
172
  let stopped = false;
118
173
  const logStatus = (status, source) => {
119
174
  if (!status)
@@ -122,8 +177,25 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
122
177
  logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
123
178
  };
124
179
  // Set initial status
125
- logStatus(currentStatus, statusText === 'Thinking...' ? 'default' : 'comment');
180
+ logStatus(currentStatus, currentSource);
126
181
  setThreadStatus(msg, currentStatus);
182
+ const cancelHold = () => {
183
+ if (holdTimer !== undefined)
184
+ clearTimeout(holdTimer);
185
+ holdTimer = undefined;
186
+ pendingCanned = null;
187
+ };
188
+ const showStatus = (text, source) => {
189
+ const changed = text !== currentStatus;
190
+ currentStatus = text;
191
+ currentSource = text ? source : 'default';
192
+ if (text && source === 'comment')
193
+ commentShownAt = Date.now();
194
+ if (changed) {
195
+ logStatus(text, source);
196
+ setThreadStatus(msg, text);
197
+ }
198
+ };
127
199
  // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
128
200
  let failures = 0;
129
201
  const interval = setInterval(async () => {
@@ -142,16 +214,33 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
142
214
  updateStatus: (newStatus, source = 'comment') => {
143
215
  if (stopped)
144
216
  return;
145
- if (newStatus !== currentStatus) {
146
- currentStatus = newStatus;
147
- logStatus(currentStatus, source);
148
- setThreadStatus(msg, currentStatus);
217
+ if (!newStatus || source === 'comment') {
218
+ cancelHold();
219
+ showStatus(newStatus, source);
220
+ return;
221
+ }
222
+ const remainingHold = config.TYPING_COMMENT_MIN_HOLD_MS - (Date.now() - commentShownAt);
223
+ if (currentSource === 'comment' && remainingHold > 0) {
224
+ pendingCanned = { text: newStatus, source };
225
+ if (holdTimer === undefined) {
226
+ holdTimer = setTimeout(() => {
227
+ holdTimer = undefined;
228
+ const pending = pendingCanned;
229
+ pendingCanned = null;
230
+ if (!stopped && pending)
231
+ showStatus(pending.text, pending.source);
232
+ }, remainingHold);
233
+ }
234
+ return;
149
235
  }
236
+ cancelHold();
237
+ showStatus(newStatus, source);
150
238
  },
151
239
  stop: () => {
152
240
  if (stopped)
153
241
  return;
154
242
  stopped = true;
243
+ cancelHold();
155
244
  clearInterval(interval);
156
245
  if (currentStatus)
157
246
  logStatus('', 'default');
@@ -0,0 +1,32 @@
1
+ import type { AccountIdentity, PoolFetchResult } from './subscription-selection.js';
2
+ /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
3
+ export declare class SubscriptionCredential {
4
+ #private;
5
+ constructor(token: string);
6
+ unwrap(): string;
7
+ toJSON(): string;
8
+ toString(): string;
9
+ }
10
+ export interface SubscriptionAccount extends AccountIdentity {
11
+ harness: 'CLAUDE';
12
+ label?: string;
13
+ credential: SubscriptionCredential;
14
+ }
15
+ export interface PoolFetchInput {
16
+ workspaceId: string;
17
+ channelId: string;
18
+ pollerId: string;
19
+ }
20
+ export interface PoolClientOptions {
21
+ apiUrl: string;
22
+ pollerToken: string;
23
+ fetch?: typeof fetch;
24
+ signal?: AbortSignal;
25
+ timeoutMs?: number;
26
+ }
27
+ /** Deadline covers response headers AND body, even for an uncooperative adapter. */
28
+ export declare function boundedRequest<T>(work: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal, timeoutMs?: number): Promise<T>;
29
+ /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
30
+ export declare function readSubscriptionJson(response: Response): Promise<unknown>;
31
+ /** No global config, logging, workspace fallback or credential cache. */
32
+ export declare function fetchSubscriptionPool(input: PoolFetchInput, options: PoolClientOptions): Promise<PoolFetchResult<SubscriptionAccount>>;
@@ -0,0 +1,139 @@
1
+ import { record } from './subscription-selection.js';
2
+ /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
3
+ export class SubscriptionCredential {
4
+ #token;
5
+ constructor(token) { this.#token = token; }
6
+ unwrap() { return this.#token; }
7
+ toJSON() { return '[REDACTED]'; }
8
+ toString() { return '[SubscriptionCredential REDACTED]'; }
9
+ [Symbol.for('nodejs.util.inspect.custom')]() { return this.toString(); }
10
+ }
11
+ /** Deadline covers response headers AND body, even for an uncooperative adapter. */
12
+ export async function boundedRequest(work, signal, timeoutMs = 15_000) {
13
+ const controller = new AbortController();
14
+ const cancel = () => controller.abort();
15
+ signal?.addEventListener('abort', cancel, { once: true });
16
+ const timer = setTimeout(cancel, timeoutMs);
17
+ if (signal?.aborted)
18
+ cancel();
19
+ let onAbort = () => { };
20
+ try {
21
+ return await Promise.race([
22
+ new Promise((_, reject) => {
23
+ onAbort = () => reject(new Error('Subscription request interrupted'));
24
+ controller.signal.addEventListener('abort', onAbort, { once: true });
25
+ if (controller.signal.aborted)
26
+ onAbort();
27
+ }),
28
+ Promise.resolve().then(() => {
29
+ if (controller.signal.aborted)
30
+ throw new Error('Subscription request interrupted');
31
+ return work(controller.signal);
32
+ }),
33
+ ]);
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ signal?.removeEventListener('abort', cancel);
38
+ controller.signal.removeEventListener('abort', onAbort);
39
+ }
40
+ }
41
+ /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
42
+ export async function readSubscriptionJson(response) {
43
+ const reader = response.body?.getReader();
44
+ if (!reader)
45
+ throw new Error('Invalid subscription response');
46
+ const chunks = [];
47
+ let size = 0;
48
+ try {
49
+ while (true) {
50
+ const chunk = await reader.read();
51
+ if (chunk.done)
52
+ break;
53
+ size += chunk.value.byteLength;
54
+ if (size > 1024 * 1024)
55
+ throw new Error('Subscription response too large');
56
+ chunks.push(chunk.value);
57
+ }
58
+ const bytes = new Uint8Array(size);
59
+ let offset = 0;
60
+ for (const chunk of chunks) {
61
+ bytes.set(chunk, offset);
62
+ offset += chunk.byteLength;
63
+ }
64
+ return JSON.parse(new TextDecoder().decode(bytes));
65
+ }
66
+ finally {
67
+ void reader.cancel().catch(() => { });
68
+ }
69
+ }
70
+ /** No global config, logging, workspace fallback or credential cache. */
71
+ export async function fetchSubscriptionPool(input, options) {
72
+ let url;
73
+ try {
74
+ if (!input.pollerId || !input.workspaceId || !input.channelId || !options.pollerToken)
75
+ throw new Error('Missing configuration');
76
+ url = new URL('/subscription-accounts/spawn', options.apiUrl);
77
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
78
+ throw new Error('Invalid URL');
79
+ url.searchParams.set('pollerId', input.pollerId);
80
+ url.searchParams.set('workspaceId', input.workspaceId);
81
+ url.searchParams.set('channelId', input.channelId);
82
+ }
83
+ catch {
84
+ return { kind: 'fetch_unavailable', reason: 'configuration' };
85
+ }
86
+ try {
87
+ return await boundedRequest(async (signal) => {
88
+ const response = await (options.fetch ?? fetch)(url, {
89
+ method: 'GET', signal,
90
+ headers: { Authorization: `Bearer ${options.pollerToken}`, 'Cache-Control': 'no-store' },
91
+ redirect: 'error',
92
+ });
93
+ if (response.status === 401 || response.status === 403)
94
+ return { kind: 'fetch_unauthorized', status: response.status };
95
+ if (!response.ok)
96
+ return { kind: 'fetch_unavailable', reason: 'http', status: response.status };
97
+ let body;
98
+ try {
99
+ body = await readSubscriptionJson(response);
100
+ }
101
+ catch {
102
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
103
+ }
104
+ const envelope = record(body);
105
+ const raw = envelope['accounts'];
106
+ if (!Array.isArray(raw) || raw.length > 1000)
107
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
108
+ const poolConfigured = envelope['poolConfigured'];
109
+ if (('poolConfigured' in envelope && typeof poolConfigured !== 'boolean') ||
110
+ (poolConfigured === false && raw.length > 0))
111
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
112
+ const accounts = [];
113
+ const ids = new Set();
114
+ for (const value of raw) {
115
+ const item = record(value);
116
+ const credential = record(item['credential']);
117
+ if (typeof item['accountId'] !== 'string' || !item['accountId'] || ids.has(item['accountId']) ||
118
+ item['workspaceId'] !== input.workspaceId || item['enabled'] !== true || item['harness'] !== 'CLAUDE' ||
119
+ typeof item['credentialVersion'] !== 'string' || !item['credentialVersion'] ||
120
+ credential['type'] !== 'claude-setup-token' || typeof credential['token'] !== 'string' || !credential['token']) {
121
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
122
+ }
123
+ ids.add(item['accountId']);
124
+ accounts.push({ accountId: item['accountId'], workspaceId: input.workspaceId, credentialVersion: item['credentialVersion'], enabled: true, harness: 'CLAUDE', ...(typeof item['label'] === 'string' && item['label'].length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(item['label']) ? { label: item['label'] } : {}), credential: new SubscriptionCredential(credential['token']) });
125
+ }
126
+ if (accounts.length)
127
+ return { kind: 'ready', accounts };
128
+ if (poolConfigured === false)
129
+ return { kind: 'no_accounts_configured' };
130
+ if (poolConfigured === true)
131
+ return { kind: 'fetch_unavailable', reason: 'no_usable_accounts' };
132
+ // Older platforms cannot distinguish an absent pool from disabled members.
133
+ return { kind: 'unknown_pool_state' };
134
+ }, options.signal, options.timeoutMs);
135
+ }
136
+ catch {
137
+ return { kind: 'fetch_unavailable', reason: options.signal?.aborted ? 'cancelled' : 'network' };
138
+ }
139
+ }
@@ -0,0 +1,18 @@
1
+ import type { resolveModel } from './claude-spawner.js';
2
+ import { type AccountIdentity, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
3
+ import { type SubscriptionUsageStore } from './subscription-usage-store.js';
4
+ export type ResolvedPoolModel = ReturnType<typeof resolveModel>;
5
+ export type ResolvedPoolSelection<A extends AccountIdentity> = (SelectionOutcome<A> | {
6
+ kind: 'usage_unavailable';
7
+ }) & ResolvedPoolModel;
8
+ /**
9
+ * #405 integration seam: resolveModel ONCE before calling, then reuse the
10
+ * returned model/modelSource for --model. No fetch, probe, spawn or env fallback
11
+ * occurs here. Only no_accounts_configured permits the legacy env credential.
12
+ */
13
+ export declare function selectPoolForResolvedModel<A extends AccountIdentity>(resolvedModel: ResolvedPoolModel, input: {
14
+ workspaceId: string;
15
+ pool: PoolFetchResult<A>;
16
+ store: SubscriptionUsageStore;
17
+ now: number;
18
+ }): Promise<ResolvedPoolSelection<A>>;