@huai_xin/dsh-workbuddy 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -22,7 +22,7 @@
22
22
  "README.md"
23
23
  ],
24
24
  "scripts": {
25
- "test": "node test/unit/plugin.mjs && node test/unit/api.mjs && node test/unit/client.mjs && node test/unit/region-order.mjs && node test/unit/catalog.mjs && node test/unit/catalog-cache.mjs && node test/unit/official-list.mjs && node test/unit/model-prefs.mjs && node test/unit/availability.mjs && node test/unit/adapter.mjs",
25
+ "test": "node test/unit/plugin.mjs && node test/unit/api.mjs && node test/unit/client.mjs && node test/unit/region-order.mjs && node test/unit/catalog.mjs && node test/unit/catalog-cache.mjs && node test/unit/official-list.mjs && node test/unit/model-prefs.mjs && node test/unit/availability.mjs && node test/unit/auth.mjs && node test/unit/adapter.mjs",
26
26
  "smoke": "node test/live/smoke.mjs"
27
27
  },
28
28
  "keywords": [
package/src/adapter.js CHANGED
@@ -18,7 +18,7 @@ import { writeFileSync } from 'node:fs'
18
18
  import { tmpdir } from 'node:os'
19
19
  import { join } from 'node:path'
20
20
 
21
- import { readToken, refreshRegion } from './auth.js'
21
+ import { accountIdOf, readToken, refreshRegion } from './auth.js'
22
22
  import { unusableModelIds, readAvailability, writeAvailability } from './availability.js'
23
23
  import { fetchModelEntries } from './catalog.js'
24
24
  import { readCatalogCache, writeCatalogCache } from './catalog-cache.js'
@@ -409,6 +409,9 @@ export class WorkBuddyAdapter {
409
409
  * `resolveModel` still answers for ids the session already knows.
410
410
  */
411
411
  async models() {
412
+ // Remember which account these verdicts belong to: the ledger and the imported
413
+ // list are per-account, the catalog itself is shared.
414
+ this.accountId = await accountIdOf(this.ctx, this.region)
412
415
  const fresh = this.catalog !== undefined && Date.now() - this.catalogAt < CATALOG_TTL_MS
413
416
  if (fresh) return this.catalog
414
417
  let loaded
@@ -419,7 +422,7 @@ export class WorkBuddyAdapter {
419
422
  }
420
423
  // An imported account list wins over whatever the sources claim: it is the
421
424
  // one thing that reflects what this account may actually pick.
422
- const imported = readOfficialList(this.region.id)
425
+ const imported = readOfficialList(this.region.id, this.accountId)
423
426
  if (loaded.entries.length > 0) {
424
427
  const applied = applyOfficialList(loaded.entries, imported)
425
428
  this.catalog = applied.entries
@@ -474,7 +477,7 @@ export class WorkBuddyAdapter {
474
477
  * model id. The panel groups by these; the picker hides `dead` ones.
475
478
  */
476
479
  availability() {
477
- return readAvailability(this.region.id).models
480
+ return readAvailability(this.region.id, this.accountId).models
478
481
  }
479
482
 
480
483
  /**
@@ -487,7 +490,7 @@ export class WorkBuddyAdapter {
487
490
  rememberUnusable(model, code, detail) {
488
491
  const text = String(detail)
489
492
  if (/11102|service info not found/i.test(text)) {
490
- writeAvailability(this.region.id, [{ id: model, state: 'dead', detail: '模型不存在(11102 service info not found)', at: Date.now() }])
493
+ writeAvailability(this.region.id, [{ id: model, state: 'dead', detail: '模型不存在(11102 service info not found)', at: Date.now() }], this.accountId)
491
494
  this.invalidateCatalog()
492
495
  // The verdict feeds the default on/off state, so the picker has to re-read.
493
496
  notifyModelTopology(this.ctx)
@@ -495,7 +498,7 @@ export class WorkBuddyAdapter {
495
498
  }
496
499
  if (/11158|is not allowed for user type/i.test(text)) {
497
500
  const type = /user type \[([^\]]{0,40})\]/i.exec(text)?.[1] ?? ''
498
- writeAvailability(this.region.id, [{ id: model, state: 'denied', detail: `账号无权限(11158${type.length > 0 ? `,用户类型 ${type}` : ''})`, at: Date.now() }])
501
+ writeAvailability(this.region.id, [{ id: model, state: 'denied', detail: `账号无权限(11158${type.length > 0 ? `,用户类型 ${type}` : ''})`, at: Date.now() }], this.accountId)
499
502
  this.invalidateCatalog()
500
503
  notifyModelTopology(this.ctx)
501
504
  }
package/src/api.js CHANGED
@@ -14,7 +14,7 @@ import { readFileSync, statSync } from 'node:fs'
14
14
  import { readFile } from 'node:fs/promises'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
17
- import { beginLogin, pendingLogins, readMeta, readToken, refreshRegion, regionStatus } from './auth.js'
17
+ import { accountIdOf, beginLogin, logoutRegion, pendingLogins, readMeta, readToken, refreshRegion, regionStatus } from './auth.js'
18
18
  import { invalidateBusy, fetchBusyLevels } from './busy.js'
19
19
  import { SWEEP_MIN_INTERVAL_MS, availabilitySummary, readAvailability, runSweep, sweepDue, unusableModelIds } from './availability.js'
20
20
  import { claimDailyCheckin, fetchCheckinStatus, fetchCredits } from './credits.js'
@@ -239,8 +239,9 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
239
239
  notifyModelTopology(ctx)
240
240
  }
241
241
  const models = await adapter.models()
242
- const ledger = readAvailability(region.id)
243
- const imported = readOfficialList(region.id)
242
+ const account = await accountIdOf(ctx, region)
243
+ const ledger = readAvailability(region.id, account)
244
+ const imported = readOfficialList(region.id, account)
244
245
  const prefs = readPrefs(region.id)
245
246
  const dead = unusableModelIds(region.id)
246
247
  sendJson(res, 200, {
@@ -463,7 +464,7 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
463
464
  }
464
465
  const adapter = adapters[region.id]
465
466
  if (method === 'GET') {
466
- const list = readOfficialList(region.id)
467
+ const list = readOfficialList(region.id, await accountIdOf(ctx, region))
467
468
  sendJson(res, 200, {
468
469
  region: region.id,
469
470
  ids: list?.ids ?? [],
@@ -483,7 +484,7 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
483
484
  return
484
485
  }
485
486
  const source = typeof body.from === 'string' && body.from.length > 0 ? body.from : 'web-session'
486
- const result = writeOfficialList(region.id, body.models, { from: source })
487
+ const result = writeOfficialList(region.id, body.models, { from: source, account: await accountIdOf(ctx, region) })
487
488
  adapter?.invalidateCatalog()
488
489
  const models = adapter === undefined ? [] : await adapter.models()
489
490
  notifyModelTopology(ctx)
@@ -549,9 +550,9 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
549
550
  return
550
551
  }
551
552
  if (path.endsWith('/logout')) {
552
- await ctx.credentials.unset(region.tokenRef)
553
- await ctx.credentials.unset(region.metaRef)
553
+ await logoutRegion(ctx, region)
554
554
  adapters[region.id]?.invalidateCatalog()
555
+ notifyModelTopology(ctx)
555
556
  sendJson(res, 200, await statePayload(ctx, adapters))
556
557
  return
557
558
  }
package/src/auth.js CHANGED
@@ -44,6 +44,40 @@ const CODE_TOKEN_NOT_READY = 11217
44
44
  /** One in-flight login per region, so a repeated command reports instead of forking a second flow. */
45
45
  export const pendingLogins = new Map()
46
46
 
47
+ /**
48
+ * Account generations, one per region.
49
+ *
50
+ * Every asynchronous path that later writes credentials or a catalog compares the
51
+ * generation it started under with the current one. Logging out or switching accounts
52
+ * bumps it, so a slow refresh, a background login poll, or a delayed catalog response
53
+ * that started before the switch can no longer resurrect the previous account.
54
+ */
55
+ const generations = new Map()
56
+
57
+ export function authGeneration(region) {
58
+ return generations.get(region.id) ?? 0
59
+ }
60
+
61
+ /** Invalidate in-flight work for this region and drop any pending login. */
62
+ export function bumpAuthGeneration(region) {
63
+ generations.set(region.id, authGeneration(region) + 1)
64
+ pendingLogins.delete(region.id)
65
+ return authGeneration(region)
66
+ }
67
+
68
+ /**
69
+ * Log out: invalidate in-flight work first, then delete the stored credentials.
70
+ *
71
+ * The order matters — a refresh that returns after the deletion would otherwise write
72
+ * the old account straight back, which is exactly the bug this replaces.
73
+ */
74
+ export async function logoutRegion(ctx, region) {
75
+ bumpAuthGeneration(region)
76
+ await ctx.credentials.unset(region.tokenRef)
77
+ await ctx.credentials.unset(region.metaRef)
78
+ ctx.logger.info(`[workbuddy] ${region.id}: 已退出登录,凭据已删除`)
79
+ }
80
+
47
81
  function authHeaders(region, extra = {}) {
48
82
  return { 'X-Domain': region.apiDomain, 'User-Agent': region.userAgent, ...extra }
49
83
  }
@@ -169,6 +203,7 @@ export async function readToken(ctx, region) {
169
203
  * Failures are logged, never thrown: a stale token must not break the plugin.
170
204
  */
171
205
  export async function refreshRegion(ctx, region, options = {}) {
206
+ const generation = authGeneration(region)
172
207
  const meta = await readMeta(ctx, region)
173
208
  if (meta === undefined || typeof meta.refreshToken !== 'string' || meta.refreshToken.length === 0) {
174
209
  return { refreshed: false, reason: '未登录' }
@@ -194,6 +229,12 @@ export async function refreshRegion(ctx, region, options = {}) {
194
229
  const data = responseData(json) ?? {}
195
230
  const nextAccess = readString(data, 'accessToken')
196
231
  if (nextAccess.length === 0) return { refreshed: false, reason: '续期响应为空' }
232
+ // Logging out or switching accounts while the refresh was in flight invalidates this
233
+ // result: writing it would put the old account back.
234
+ if (authGeneration(region) !== generation) {
235
+ ctx.logger.info(`[workbuddy] ${region.id}: 续期结果已过期(期间退出或切换账号),不写回`)
236
+ return { refreshed: false, reason: '账号已切换' }
237
+ }
197
238
 
198
239
  const now = Date.now()
199
240
  const nextRefresh = readString(data, 'refreshToken')
@@ -220,25 +261,62 @@ export async function beginLogin(ctx, region, options = {}) {
220
261
  const existing = pendingLogins.get(region.id)
221
262
  if (existing !== undefined) return { started: false, url: existing.url, pending: true }
222
263
 
223
- const { state, authUrl } = await fetchAuthState(region)
224
- const decorated = decorateAuthUrl(authUrl, region)
225
- pendingLogins.set(region.id, { url: decorated, startedAt: Date.now() })
226
- if (options.open !== false) openInBrowser(decorated)
264
+ // Claimed before the request goes out: two concurrent calls would otherwise both
265
+ // start a flow, and the first to fail would delete the second one's pending entry.
266
+ const generation = authGeneration(region)
267
+ pendingLogins.set(region.id, { url: '', startedAt: Date.now() })
268
+ let authUrl
269
+ let state
270
+ try {
271
+ const fetched = await fetchAuthState(region)
272
+ state = fetched.state
273
+ authUrl = decorateAuthUrl(fetched.authUrl, region)
274
+ pendingLogins.set(region.id, { url: authUrl, startedAt: Date.now() })
275
+ } catch (error) {
276
+ pendingLogins.delete(region.id)
277
+ throw error
278
+ }
279
+ if (options.open !== false) openInBrowser(authUrl)
227
280
 
228
281
  void (async () => {
229
282
  try {
230
283
  const token = await pollAuthToken(region, state)
284
+ if (authGeneration(region) !== generation) {
285
+ ctx.logger.info(`[workbuddy] ${region.id}: 登录结果已过期(期间退出或切换账号),不写回`)
286
+ return
287
+ }
231
288
  const account = await fetchAccount(region, token, state)
289
+ if (authGeneration(region) !== generation) {
290
+ ctx.logger.info(`[workbuddy] ${region.id}: 登录结果已过期(期间退出或切换账号),不写回`)
291
+ return
292
+ }
232
293
  const stored = await storeCredential(ctx, region, token, account)
233
294
  ctx.logger.info(`[workbuddy] ${region.id}: 已登录 ${stored.nickname || stored.uid || '(未知)'}`)
234
295
  await options.onSuccess?.(stored)
235
296
  } catch (error) {
236
297
  ctx.logger.warn(`[workbuddy] ${region.id}: 登录失败 — ${error instanceof Error ? error.message : String(error)}`)
237
298
  } finally {
238
- pendingLogins.delete(region.id)
299
+ // Only clear the entry this flow created; a newer flow owns its own.
300
+ if (authGeneration(region) === generation) pendingLogins.delete(region.id)
239
301
  }
240
302
  })()
241
- return { started: true, url: decorated, pending: false }
303
+ return { started: true, url: authUrl, pending: false }
304
+ }
305
+
306
+ /**
307
+ * A stable identity for the logged-in account.
308
+ *
309
+ * Per-account records (availability verdicts, imported model lists) use it so one
310
+ * account's entitlements do not leak into another's. The catalog itself stays shared:
311
+ * which models a deployment offers is not an account fact.
312
+ */
313
+ export async function accountIdOf(ctx, region) {
314
+ const meta = await readMeta(ctx, region)
315
+ const uid = meta?.uid
316
+ if (typeof uid === 'string' && uid.length > 0) return uid
317
+ if (typeof uid === 'number') return String(uid)
318
+ const nickname = typeof meta?.nickname === 'string' ? meta.nickname.trim() : ''
319
+ return nickname.length > 0 ? `nick:${nickname}` : undefined
242
320
  }
243
321
 
244
322
  /** One-line account summary for the command and the panel. */
@@ -32,7 +32,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
32
32
  import { homedir } from 'node:os'
33
33
  import { join } from 'node:path'
34
34
 
35
- import { readToken } from './auth.js'
35
+ import { accountIdOf, readToken } from './auth.js'
36
36
  import { chatRoutes } from './regions.js'
37
37
 
38
38
  const LEDGER_VERSION = 1
@@ -210,16 +210,19 @@ export async function probeModel(ctx, region, id, options = {}) {
210
210
  export const SWEEP_MIN_INTERVAL_MS = 24 * 3600_000
211
211
 
212
212
  /** Whether a sweep may run now; `manual` callers skip this check. */
213
- export function sweepDue(regionId, minIntervalMs = SWEEP_MIN_INTERVAL_MS, now = Date.now()) {
214
- const last = readAvailability(regionId).lastSweepAt
213
+ export function sweepDue(regionId, minIntervalMs = SWEEP_MIN_INTERVAL_MS, now = Date.now(), account) {
214
+ const last = readAvailability(regionId, account).lastSweepAt
215
215
  return { due: now - last >= minIntervalMs, lastSweepAt: last }
216
216
  }
217
217
 
218
218
  /** Probe a list of ids, a few at a time, and persist every verdict. */
219
219
  export async function runSweep(ctx, region, ids, options = {}) {
220
220
  const interval = options.minIntervalMs ?? SWEEP_MIN_INTERVAL_MS
221
+ // Verdicts describe one account's entitlements, so the sweep is scoped to whoever is
222
+ // logged in now: a new account starts from an empty ledger and gets its own probes.
223
+ const account = await accountIdOf(ctx, region)
221
224
  if (options.manual !== true) {
222
- const due = sweepDue(region.id, interval)
225
+ const due = sweepDue(region.id, interval, Date.now(), account)
223
226
  if (due.due !== true) return { skipped: true, reason: 'interval', lastSweepAt: due.lastSweepAt, results: [] }
224
227
  }
225
228
  const token = await readToken(ctx, region)
@@ -236,32 +239,48 @@ export async function runSweep(ctx, region, ids, options = {}) {
236
239
  }
237
240
  }
238
241
  await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()))
239
- if (results.length > 0) writeAvailability(region.id, results)
240
- markSwept(region.id)
241
- return { skipped: false, results }
242
+ if (results.length > 0) writeAvailability(region.id, results, account)
243
+ markSwept(region.id, account)
244
+ return { skipped: false, results, account }
242
245
  }
243
246
 
244
247
  /** @returns {{ savedAt: number, lastSweepAt: number, models: Map<string, {state: string, detail: string, at: number}> }} */
245
- export function readAvailability(regionId) {
248
+ /** Read the ledger as stored (no account filtering). */
249
+ function readLedger(regionId) {
246
250
  try {
247
251
  const parsed = JSON.parse(readFileSync(ledgerPath(regionId), 'utf8'))
248
252
  if (parsed?.version !== LEDGER_VERSION || typeof parsed.models !== 'object' || parsed.models === null) {
249
- return { savedAt: 0, lastSweepAt: 0, models: new Map() }
253
+ return { account: undefined, savedAt: 0, lastSweepAt: 0, models: new Map() }
250
254
  }
251
255
  return {
256
+ account: typeof parsed.account === 'string' && parsed.account.length > 0 ? parsed.account : undefined,
252
257
  savedAt: typeof parsed.savedAt === 'number' ? parsed.savedAt : 0,
253
258
  lastSweepAt: typeof parsed.lastSweepAt === 'number' ? parsed.lastSweepAt : 0,
254
259
  models: new Map(Object.entries(parsed.models))
255
260
  }
256
261
  } catch {
262
+ return { account: undefined, savedAt: 0, lastSweepAt: 0, models: new Map() }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * @param regionId deployment id
268
+ * @param account when given, a ledger recorded for another account reads as empty
269
+ * @returns {{ savedAt: number, lastSweepAt: number, models: Map<string, {state: string, detail: string, at: number}> }}
270
+ */
271
+ export function readAvailability(regionId, account) {
272
+ const ledger = readLedger(regionId)
273
+ if (account !== undefined && ledger.account !== undefined && ledger.account !== account) {
257
274
  return { savedAt: 0, lastSweepAt: 0, models: new Map() }
258
275
  }
276
+ return { savedAt: ledger.savedAt, lastSweepAt: ledger.lastSweepAt, models: ledger.models }
259
277
  }
260
278
 
261
279
  function store(regionId, ledger, now) {
262
280
  mkdirSync(ledgerDir(), { recursive: true })
263
281
  writeFileSync(ledgerPath(regionId), JSON.stringify({
264
282
  version: LEDGER_VERSION,
283
+ ...(ledger.account === undefined ? {} : { account: ledger.account }),
265
284
  savedAt: now,
266
285
  lastSweepAt: ledger.lastSweepAt,
267
286
  models: Object.fromEntries(ledger.models)
@@ -269,10 +288,18 @@ function store(regionId, ledger, now) {
269
288
  }
270
289
 
271
290
  /** Merge verdicts by id (a fresh verdict always wins) and store them. */
272
- export function writeAvailability(regionId, results) {
291
+ export function writeAvailability(regionId, results, account) {
273
292
  try {
274
- const ledger = readAvailability(regionId)
293
+ const ledger = readLedger(regionId)
275
294
  const now = Date.now()
295
+ // A verdict describes one account's entitlements. Writing a different account's
296
+ // results into the same ledger is what made a new login inherit the previous
297
+ // account's `denied` marks for twelve hours, so a different account starts clean.
298
+ if (account !== undefined && ledger.account !== account) {
299
+ ledger.account = account
300
+ ledger.models = new Map()
301
+ ledger.lastSweepAt = 0
302
+ }
276
303
  for (const result of results) {
277
304
  if (typeof result?.id !== 'string' || result.id.length === 0) continue
278
305
  ledger.models.set(result.id, {
@@ -290,9 +317,10 @@ export function writeAvailability(regionId, results) {
290
317
  }
291
318
 
292
319
  /** Record that a full sweep just ran, which is what the daily limit reads. */
293
- function markSwept(regionId) {
320
+ function markSwept(regionId, account) {
294
321
  try {
295
- const ledger = readAvailability(regionId)
322
+ const ledger = readLedger(regionId)
323
+ if (account !== undefined && ledger.account !== undefined && ledger.account !== account) return false
296
324
  ledger.lastSweepAt = Date.now()
297
325
  store(regionId, ledger, Date.now())
298
326
  return true
@@ -302,9 +330,9 @@ function markSwept(regionId) {
302
330
  }
303
331
 
304
332
  /** Ids the ledger considers unusable, within the verdict's TTL. */
305
- export function unusableModelIds(regionId, now = Date.now()) {
333
+ export function unusableModelIds(regionId, now = Date.now(), account) {
306
334
  const unusable = new Set()
307
- for (const [id, entry] of readAvailability(regionId).models) {
335
+ for (const [id, entry] of readAvailability(regionId, account).models) {
308
336
  if (!UNUSABLE_STATES.has(entry?.state)) continue
309
337
  if (typeof entry.at !== 'number' || now - entry.at > AVAILABILITY_TTL_MS) continue
310
338
  unusable.add(id)
@@ -313,9 +341,9 @@ export function unusableModelIds(regionId, now = Date.now()) {
313
341
  }
314
342
 
315
343
  /** Kept for callers that only care about delisted models. */
316
- export function deadModelIds(regionId, now = Date.now()) {
344
+ export function deadModelIds(regionId, now = Date.now(), account) {
317
345
  const dead = new Set()
318
- for (const [id, entry] of readAvailability(regionId).models) {
346
+ for (const [id, entry] of readAvailability(regionId, account).models) {
319
347
  if (entry?.state !== 'dead') continue
320
348
  if (typeof entry.at !== 'number' || now - entry.at > AVAILABILITY_TTL_MS) continue
321
349
  dead.add(id)
@@ -324,8 +352,8 @@ export function deadModelIds(regionId, now = Date.now()) {
324
352
  }
325
353
 
326
354
  /** One-line summary for the panel and the command. */
327
- export function availabilitySummary(regionId) {
328
- const ledger = readAvailability(regionId)
355
+ export function availabilitySummary(regionId, account) {
356
+ const ledger = readAvailability(regionId, account)
329
357
  const counts = new Map()
330
358
  for (const entry of ledger.models.values()) {
331
359
  const state = String(entry?.state ?? 'error')
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
30
- import { homedir, tmpdir } from 'node:os'
30
+ import { homedir } from 'node:os'
31
31
  import { join } from 'node:path'
32
32
 
33
33
  import { isCatalogModelId, keepModel, withConsistentEfforts } from './catalog.js'
@@ -143,7 +143,9 @@ export function writeCatalogCache(regionId, source, entries) {
143
143
 
144
144
  const dir = cacheDir()
145
145
  mkdirSync(dir, { recursive: true })
146
- const staging = join(tmpdir(), `dsh-workbuddy-catalog-${regionId}-${process.pid}.json`)
146
+ // Staged inside the target directory: a temp file on another volume makes the
147
+ // rename below fail with EXDEV, and by then the current file has already been moved.
148
+ const staging = join(dir, `.catalog-${regionId}-${process.pid}.json`)
147
149
  writeFileSync(staging, JSON.stringify({
148
150
  version: CACHE_VERSION,
149
151
  savedAt: now,
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import { randomUUID } from 'node:crypto'
17
17
 
18
18
  import { WorkBuddyAdapter } from './adapter.js'
19
19
  import { registerWorkbuddyApi } from './api.js'
20
- import { REFRESH_BOOT_DELAY_MS, REFRESH_INTERVAL_MS, beginLogin, refreshRegion, regionStatus } from './auth.js'
20
+ import { REFRESH_BOOT_DELAY_MS, REFRESH_INTERVAL_MS, beginLogin, logoutRegion, refreshRegion, regionStatus } from './auth.js'
21
21
  import { runSweep, sweepDue } from './availability.js'
22
22
  import { claimDailyCheckin, runAutoClaim } from './credits.js'
23
23
  import { msUntilNextMidnight } from './http.js'
@@ -53,8 +53,12 @@ async function executeCommand(ctx, adapters, invocation) {
53
53
  for (const region of regions) {
54
54
  try {
55
55
  const result = await beginLogin(ctx, region, {
56
- onSuccess: () => {
56
+ onSuccess: async () => {
57
57
  adapters[region.id]?.invalidateCatalog()
58
+ // The availability ledger belongs to the account, so a new login starts with
59
+ // no verdicts: probe this account once, which is how "which models can this
60
+ // account use" gets answered (the catalog itself stays shared).
61
+ void probeAccount(region)
58
62
  // A fresh account usually has today's check-in waiting.
59
63
  void claimDailyCheckin(ctx, region).then(async (outcome) => {
60
64
  if (outcome.claimed) ctx.logger.info(`[workbuddy] ${region.id}: 登录后签到成功,获得 ${outcome.credit ?? 0} 积分`)
@@ -74,9 +78,9 @@ async function executeCommand(ctx, adapters, invocation) {
74
78
  if (verb === 'logout') {
75
79
  const lines = []
76
80
  for (const region of regions) {
77
- await ctx.credentials.unset(region.tokenRef)
78
- await ctx.credentials.unset(region.metaRef)
81
+ await logoutRegion(ctx, region)
79
82
  adapters[region.id]?.invalidateCatalog()
83
+ notifyModelTopology(ctx)
80
84
  lines.push(`${region.label}:凭据已清除`)
81
85
  }
82
86
  return { kind: 'success', text: lines.join('\n') }
@@ -241,6 +245,24 @@ export function apply(ctx) {
241
245
  // run every time the panel opens (its probe never generates), the
242
246
  // international one once a day (its probe costs a token per model).
243
247
  const AVAILABILITY_BOOT_DELAY_MS = 45_000
248
+ /** Probe every model once for the account that just logged in. */
249
+ const probeAccount = async (region) => {
250
+ const adapter = adapters[region.id]
251
+ if (adapter === undefined) return
252
+ try {
253
+ adapter.invalidateCatalog()
254
+ const entries = await adapter.models()
255
+ if (entries.length === 0) return
256
+ const result = await runSweep(ctx, region, entries.map((entry) => entry.id), { manual: true })
257
+ if (result.skipped === true) return
258
+ const counts = new Map()
259
+ for (const verdict of result.results) counts.set(verdict.state, (counts.get(verdict.state) ?? 0) + 1)
260
+ ctx.logger.info(`[workbuddy] ${region.id}: 登录后可用性检测完成 ${result.results.length} 个 — ${[...counts.entries()].map(([state, count]) => `${state} ${count}`).join(' / ')}`)
261
+ notifyModelTopology(ctx)
262
+ } catch (error) {
263
+ ctx.logger.warn(`[workbuddy] ${region.id}: 登录后可用性检测失败 — ${error instanceof Error ? error.message : String(error)}`)
264
+ }
265
+ }
244
266
  const availabilityTimer = setTimeout(() => {
245
267
  for (const id of REGION_IDS) {
246
268
  const region = REGIONS[id]
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
21
- import { homedir, tmpdir } from 'node:os'
21
+ import { homedir } from 'node:os'
22
22
  import { join } from 'node:path'
23
23
 
24
24
  const PREFS_VERSION = 1
@@ -54,7 +54,8 @@ export function readPrefs(regionId) {
54
54
  function write(regionId, prefs) {
55
55
  try {
56
56
  mkdirSync(prefsDir(), { recursive: true })
57
- const staging = join(tmpdir(), `dsh-workbuddy-enabled-${regionId}-${process.pid}.json`)
57
+ // Staged in the target directory so the rename cannot fail with EXDEV.
58
+ const staging = join(prefsDir(), `.enabled-${regionId}-${process.pid}.json`)
58
59
  writeFileSync(staging, JSON.stringify({
59
60
  version: PREFS_VERSION,
60
61
  updatedAt: Date.now(),
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
29
- import { homedir, tmpdir } from 'node:os'
29
+ import { homedir } from 'node:os'
30
30
  import { join } from 'node:path'
31
31
 
32
32
  import { entryFromMeta, isAdvertisableId } from './catalog.js'
@@ -58,10 +58,13 @@ function normalizeItem(item) {
58
58
  }
59
59
 
60
60
  /** @returns {{ ids: string[], entries: object[], importedAt: number, from: string } | undefined} */
61
- export function readOfficialList(regionId) {
61
+ export function readOfficialList(regionId, account) {
62
62
  try {
63
63
  const parsed = JSON.parse(readFileSync(listPath(regionId), 'utf8'))
64
64
  if (parsed?.version !== LIST_VERSION || !Array.isArray(parsed.ids)) return undefined
65
+ // An imported list is one account's entitlements; another account must not inherit
66
+ // it, or a freshly logged-in account would show the previous account's models.
67
+ if (account !== undefined && typeof parsed.account === 'string' && parsed.account !== account) return undefined
65
68
  const ids = parsed.ids.filter((id) => typeof id === 'string' && id.length > 0)
66
69
  if (ids.length === 0) return undefined
67
70
  const entries = Array.isArray(parsed.entries) ? parsed.entries : []
@@ -109,9 +112,11 @@ export function writeOfficialList(regionId, models, options = {}) {
109
112
  const ids = [...byId.keys()]
110
113
  const entries = ids.filter((id) => byId.get(id).rich).map((id) => byId.get(id).entry)
111
114
  mkdirSync(listDir(), { recursive: true })
112
- const staging = join(tmpdir(), `dsh-workbuddy-official-${regionId}-${process.pid}.json`)
115
+ // Staged in the target directory so the rename cannot fail with EXDEV.
116
+ const staging = join(listDir(), `.official-${regionId}-${process.pid}.json`)
113
117
  writeFileSync(staging, JSON.stringify({
114
118
  version: LIST_VERSION,
119
+ ...(typeof options.account === 'string' && options.account.length > 0 ? { account: options.account } : {}),
115
120
  importedAt: Date.now(),
116
121
  from: typeof options.from === 'string' && options.from.length > 0 ? options.from : 'import',
117
122
  ids,