dsh-all-usage 1.1.9 → 1.1.12

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/lib/http.js CHANGED
@@ -249,6 +249,38 @@ export function registerRoutes(host) {
249
249
  sendJson(res, 200, statusSnapshot())
250
250
  },
251
251
  }))
252
+ ctx.effect(() => webServer.register({
253
+ kind: 'exact',
254
+ path: '/api/all-usage/client-env',
255
+ handler: (req, res) => {
256
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
257
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
258
+ // Diagnostics only: the client reports what the host exposes (user agent,
259
+ // window-controls-overlay geometry, the strip it reserved). Truncated and
260
+ // kept in memory for the life of the process.
261
+ try {
262
+ const url = new URL(req.url ?? '/', 'http://x')
263
+ const read = (name, limit) => {
264
+ const value = url.searchParams.get(name)
265
+ return value === null ? null : value.slice(0, limit)
266
+ }
267
+ state.clientEnv = {
268
+ reportedAt: Date.now(),
269
+ ua: read('ua', 200),
270
+ wco: read('wco', 40),
271
+ inset: read('inset', 12),
272
+ insetSource: read('src', 24),
273
+ titlebar: read('tb', 24),
274
+ viewport: read('vp', 24),
275
+ modalRect: read('modal', 40),
276
+ dialogRect: read('dlg', 40),
277
+ headRect: read('head', 40),
278
+ closeRect: read('close', 40),
279
+ }
280
+ } catch (err) { /* ignore a malformed diagnostic */ }
281
+ sendJson(res, 200, { ok: true })
282
+ },
283
+ }))
252
284
  ctx.effect(() => webServer.register({
253
285
  kind: 'exact',
254
286
  path: '/api/all-usage',
package/lib/plugin.js CHANGED
@@ -24,6 +24,9 @@ function apply(ctx) {
24
24
  // shared "deleted" bucket; the bucket row is only advertised when it holds
25
25
  // something.
26
26
  retiredBucketUsed: false,
27
+ // Last client-side environment report (diagnostics only): the desktop host
28
+ // cannot be inspected from outside the app.
29
+ clientEnv: null,
27
30
  pathIndex: new Map(),
28
31
  memberOf: new Map(),
29
32
  byDay: new Map(),
@@ -70,6 +73,14 @@ function apply(ctx) {
70
73
  sessionsSkippedByRevision: 0,
71
74
  sessionsRestoredFromLedger: 0,
72
75
  sessionsFailed: 0,
76
+ // Cumulative registry-probe diagnostics. They deliberately survive a
77
+ // baseline reset so the status endpoint can show whether the
78
+ // `domain/changed` fast path fires at all on this host.
79
+ workspaceProbeEvents: 0,
80
+ workspaceSyncRuns: 0,
81
+ workspaceSyncAdded: 0,
82
+ workspaceSyncRemoved: 0,
83
+ workspaceSyncDeferred: 0,
73
84
  },
74
85
  ledgerRecords: new Map(),
75
86
  queryCache: new Map(),
@@ -105,6 +116,8 @@ function apply(ctx) {
105
116
  reconcileInFlight: false,
106
117
  reconcileTimer: null,
107
118
  workspaceSyncTimer: null,
119
+ workspaceSyncDeferred: false,
120
+ workspaceProbeTimer: null,
108
121
  workspaceSyncInFlight: false,
109
122
  workspaceSyncPending: false,
110
123
  }
@@ -172,6 +185,11 @@ function apply(ctx) {
172
185
  sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
173
186
  sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
174
187
  sessionsFailed: state.sync.sessionsFailed,
188
+ workspaceProbeEvents: state.sync.workspaceProbeEvents,
189
+ workspaceSyncRuns: state.sync.workspaceSyncRuns,
190
+ workspaceSyncAdded: state.sync.workspaceSyncAdded,
191
+ workspaceSyncRemoved: state.sync.workspaceSyncRemoved,
192
+ workspaceSyncDeferred: state.sync.workspaceSyncDeferred,
175
193
  }
176
194
  }
177
195
  Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
@@ -277,6 +295,7 @@ function apply(ctx) {
277
295
  state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
278
296
  void host.sessionSync.runBaseline()
279
297
  host.sessionSync.scheduleReconcileTimer()
298
+ host.sessionSync.scheduleWorkspaceProbeTimer()
280
299
  state.aliasesReady = loadAliases()
281
300
  }
282
301
 
@@ -4,6 +4,10 @@ import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes,
4
4
  const RECONCILE_INTERVAL_MS = 120000
5
5
  const RECONCILE_HINT_DELAY_MS = 3000
6
6
  const WORKSPACE_SYNC_DEBOUNCE_MS = 300
7
+ // The registry event is a fast path, not a guarantee: a change that lands while a
8
+ // baseline runs used to be dropped outright, and a missed event left the plugin
9
+ // stale until the next restart. The (in-memory) registry list is polled as well.
10
+ const WORKSPACE_PROBE_INTERVAL_MS = 30000
7
11
 
8
12
  export function createSessionSync(host) {
9
13
  const { ctx, state } = host
@@ -154,6 +158,25 @@ export function createSessionSync(host) {
154
158
  if (typeof cwd !== 'string' || cwd === '') return undefined
155
159
  return state.pathIndex.get(workspacePathKey(cwd))
156
160
  }
161
+ // DSH 0.1.5 replaced \`sessionPersistence.listSnapshots()\` with \`list()\`; both
162
+ // return per-session \`{ header, revision }\` rows. Without a revision the
163
+ // ledger-reuse fast path never fires, so every baseline re-reads every log.
164
+ async function loadSessionRevisions() {
165
+ const source = sessionPersistence
166
+ if (source === undefined || source === null) return null
167
+ const read = typeof source.list === 'function'
168
+ ? () => source.list()
169
+ : (typeof source.listSnapshots === 'function' ? () => source.listSnapshots() : null)
170
+ if (read === null) return null
171
+ const rows = await read()
172
+ if (!Array.isArray(rows)) return null
173
+ const revisions = new Map()
174
+ for (const row of rows) {
175
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
176
+ if (rid !== undefined && row && typeof row.revision === 'string') revisions.set(rid, row.revision)
177
+ }
178
+ return revisions
179
+ }
157
180
  function wsForLiveSession(session, sid) {
158
181
  let wsId = state.memberOf.get(sid)
159
182
  if (wsId !== undefined) return wsId
@@ -406,24 +429,18 @@ export function createSessionSync(host) {
406
429
  if (state.disposed || generation !== state.aggregationGeneration) return
407
430
  // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
408
431
  let snapshots = null
409
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
410
- try {
411
- const rows = await sessionPersistence.listSnapshots()
412
- if (state.disposed || generation !== state.aggregationGeneration) return
413
- if (Array.isArray(rows)) {
414
- state.sync.persistenceSnapshotsAvailable = true
415
- snapshots = new Map()
416
- for (const row of rows) {
417
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
418
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
419
- }
420
- }
421
- } catch (err) {
422
- console.error('[all-usage] session persistence snapshots unavailable:', err)
423
- if (state.disposed || generation !== state.aggregationGeneration) return
424
- state.sync.persistenceSnapshotsAvailable = false
425
- markStatsChanged('scan')
432
+ try {
433
+ const revisions = await loadSessionRevisions()
434
+ if (state.disposed || generation !== state.aggregationGeneration) return
435
+ if (revisions !== null) {
436
+ state.sync.persistenceSnapshotsAvailable = true
437
+ snapshots = revisions
426
438
  }
439
+ } catch (err) {
440
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
441
+ if (state.disposed || generation !== state.aggregationGeneration) return
442
+ state.sync.persistenceSnapshotsAvailable = false
443
+ markStatsChanged('scan')
427
444
  }
428
445
  if (state.disposed || generation !== state.aggregationGeneration) return
429
446
  if (setupFailed || !Array.isArray(records)) {
@@ -504,6 +521,10 @@ export function createSessionSync(host) {
504
521
  state.knownSessionIds.clear()
505
522
  for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
506
523
  state.scan.done = true
524
+ if (state.workspaceSyncDeferred) {
525
+ state.workspaceSyncDeferred = false
526
+ scheduleWorkspaceSync()
527
+ }
507
528
  state.sync.lastCompletedAt = Date.now()
508
529
  if (state.sync.sessionsFailed === 0) {
509
530
  state.sync.lastErrorAt = 0
@@ -529,6 +550,7 @@ export function createSessionSync(host) {
529
550
  return
530
551
  }
531
552
  state.workspaceSyncInFlight = true
553
+ state.sync.workspaceSyncRuns += 1
532
554
  try {
533
555
  await synchronizeWorkspaceRegistry()
534
556
  } catch (err) {
@@ -543,9 +565,15 @@ export function createSessionSync(host) {
543
565
  }
544
566
  }
545
567
  async function synchronizeWorkspaceRegistry() {
546
- // The first baseline reads the registry with the latest list, so a probe
547
- // arriving before it completes has nothing to add.
548
- if (state.disposed || !state.scan.done) return
568
+ if (state.disposed) return
569
+ // A registry change that lands while a baseline is running used to be
570
+ // dropped outright, which is why a new workspace could stay invisible until
571
+ // the next restart. Remember it and re-run once the scan settles.
572
+ if (!state.scan.done) {
573
+ state.workspaceSyncDeferred = true
574
+ state.sync.workspaceSyncDeferred += 1
575
+ return
576
+ }
549
577
  let workspaces
550
578
  try {
551
579
  workspaces = ctx.workspaceRegistry.list()
@@ -559,6 +587,8 @@ export function createSessionSync(host) {
559
587
  for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
560
588
  const addedWs = new Set()
561
589
  for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
590
+ state.sync.workspaceSyncAdded += addedWs.size
591
+ state.sync.workspaceSyncRemoved += removedWs.size
562
592
  // Reuse path: no membership change — metadata only, zero rescan.
563
593
  if (removedWs.size === 0 && addedWs.size === 0) {
564
594
  installWorkspaceSnapshot(next)
@@ -606,20 +636,10 @@ export function createSessionSync(host) {
606
636
  state.sync.sessionsTotal = records.length
607
637
  markStatsChanged('scan')
608
638
  let snapshots = null
609
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
610
- try {
611
- const rows = await sessionPersistence.listSnapshots()
612
- if (Array.isArray(rows)) {
613
- snapshots = new Map()
614
- for (const row of rows) {
615
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
616
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
617
- }
618
- }
619
- } catch (err) {
620
- console.error('[all-usage] session persistence snapshots unavailable:', err)
621
- markStatsChanged('scan')
622
- }
639
+ try {
640
+ snapshots = await loadSessionRevisions()
641
+ } catch (err) {
642
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
623
643
  }
624
644
  for (const record of records) {
625
645
  if (state.disposed) return
@@ -698,6 +718,20 @@ export function createSessionSync(host) {
698
718
  }, RECONCILE_INTERVAL_MS)
699
719
  if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
700
720
  }
721
+ // Belt and braces for the registry probe: the event fast path can be missed
722
+ // (dropped while scanning, or never delivered), and unlike the event this
723
+ // cannot silently leave the workspace list stale until a restart.
724
+ function scheduleWorkspaceProbeTimer() {
725
+ if (state.disposed || state.workspaceProbeTimer !== null) return
726
+ state.workspaceProbeTimer = setTimeout(() => {
727
+ state.workspaceProbeTimer = null
728
+ if (!state.disposed) {
729
+ void runWorkspaceSync()
730
+ scheduleWorkspaceProbeTimer()
731
+ }
732
+ }, WORKSPACE_PROBE_INTERVAL_MS)
733
+ if (state.workspaceProbeTimer && typeof state.workspaceProbeTimer.unref === 'function') state.workspaceProbeTimer.unref()
734
+ }
701
735
 
702
736
  // ---------- live feed ----------
703
737
  ctx.on('session/event', (session, event) => {
@@ -775,6 +809,7 @@ export function createSessionSync(host) {
775
809
  if (change === undefined || change === null) return
776
810
  if (change.domain !== 'workspace') return
777
811
  if (change.table !== 'workspaces' && change.table !== '') return
812
+ state.sync.workspaceProbeEvents += 1
778
813
  scheduleWorkspaceSync()
779
814
  })
780
815
 
@@ -800,6 +835,7 @@ export function createSessionSync(host) {
800
835
  reconcileSessions,
801
836
  scheduleReconcileHint,
802
837
  scheduleReconcileTimer,
838
+ scheduleWorkspaceProbeTimer,
803
839
  markLedgerDirty
804
840
  }
805
841
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.9",
3
+ "version": "1.1.12",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",