@linxin666/dsh-remote-web-ui 0.1.14 → 0.1.16

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 (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -5
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +84 -23
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +173 -18
  7. package/lib/mobile.js +44 -25
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +30 -8
  12. package/lib/types/client/UpdatePanel.js +2 -0
  13. package/lib/types/client/locales.d.ts +4 -0
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +4 -0
  16. package/lib/types/client/settings-form.d.ts +76 -5
  17. package/lib/types/client/settings-form.d.ts.map +1 -1
  18. package/lib/types/client/settings-form.js +97 -20
  19. package/lib/types/index.d.ts.map +1 -1
  20. package/lib/types/index.js +36 -10
  21. package/lib/types/mobile/mux.d.ts +13 -4
  22. package/lib/types/mobile/mux.d.ts.map +1 -1
  23. package/lib/types/mobile/mux.js +50 -30
  24. package/lib/types/mobile/mux.test.js +75 -6
  25. package/lib/types/mobile-api.d.ts +2 -0
  26. package/lib/types/mobile-api.d.ts.map +1 -1
  27. package/lib/types/mobile-api.js +4 -1
  28. package/lib/types/routes.d.ts +17 -0
  29. package/lib/types/routes.d.ts.map +1 -1
  30. package/lib/types/routes.js +49 -10
  31. package/lib/types/update.d.ts +44 -4
  32. package/lib/types/update.d.ts.map +1 -1
  33. package/lib/types/update.js +118 -8
  34. package/package.json +1 -1
  35. package/src/client/PluginSettingsCard.tsx +90 -18
  36. package/src/client/UpdatePanel.tsx +2 -0
  37. package/src/client/locales.ts +4 -0
  38. package/src/client/settings-card.module.css +9 -0
  39. package/src/client/settings-form.ts +153 -19
  40. package/src/index.ts +34 -10
  41. package/src/mobile/mux.test.ts +84 -6
  42. package/src/mobile/mux.ts +46 -25
  43. package/src/mobile-api.ts +6 -1
  44. package/src/routes.ts +51 -10
  45. package/src/update.ts +128 -9
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -19,6 +20,14 @@ export type FieldWrite =
19
20
  export interface FieldSpec {
20
21
  /** Field name inside the namespace section. */
21
22
  field: string
23
+ /**
24
+ * Whether the Host treats this field as a secret and redacts its value from
25
+ * the read-back (role('secret') in the section schema). Redacted secrets are
26
+ * never compared against the draft on save; the field lands when the scope
27
+ * reports the write succeeded (its secret-set marker under the bridge), so
28
+ * a successful secret save is not misreported as failed.
29
+ */
30
+ secret?: boolean
22
31
  /** Render a stored value as draft text; the empty string when the section carries none. */
23
32
  format: (value: unknown) => string
24
33
  /**
@@ -60,6 +69,12 @@ export interface CardShell {
60
69
  saving: boolean
61
70
  /** Whether the last save did not land as staged; cleared by the next edit or save. */
62
71
  failed: boolean
72
+ /**
73
+ * The rejection code/message the Host returned for the last failed save,
74
+ * surfaced next to the generic failure text. Undefined while no save has
75
+ * failed (or the failure carried no server reason).
76
+ */
77
+ failedReason?: string
63
78
  }
64
79
 
65
80
  /** The write actions the card's slot entry injects. */
@@ -86,12 +101,63 @@ interface StagedEdit {
86
101
  interface PlannedWrite {
87
102
  /** Field this entry writes. */
88
103
  field: string
104
+ /** The durable write this entry performs, described for a batched scope. */
105
+ op: BatchedWrite
89
106
  /** Perform the write and report whether the Host holds the staged value afterwards. */
90
107
  run: (() => Promise<boolean>) | undefined
91
108
  }
92
109
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
110
+ /** One durable write a batched settings scope performs. */
111
+ export interface BatchedWrite {
112
+ /** Field this entry writes. */
113
+ field: string
114
+ /** set stores a value; unset drops the leaf. */
115
+ op: 'set' | 'unset'
116
+ /** Value for op set (absent for unset). */
117
+ value?: unknown
118
+ }
119
+
120
+ /** Per-field outcome of one batched scope write. */
121
+ export interface BatchedFieldResult {
122
+ /** Field this entry writes. */
123
+ field: string
124
+ /** Whether the Host accepted this field's write (per the read-back view). */
125
+ landed: boolean
126
+ }
127
+
128
+ /**
129
+ * Result of a batched scope write. The bridge scope posts every planned write
130
+ * in one /mutate so the Host validate hook judges baseURL+model together; a
131
+ * batched refusal fails the whole save rather than per-field.
132
+ */
133
+ export interface BatchResult {
134
+ /** Whether the whole mutate was accepted. */
135
+ ok: boolean
136
+ /** Per-field success, in the request order (always present when ok). */
137
+ fields: BatchedFieldResult[]
138
+ /** Host rejection code (mutate refused). */
139
+ code?: string
140
+ /** Host rejection message (mutate refused). */
141
+ message?: string
142
+ }
143
+
144
+ /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
+ interface BatchedSettingsScope {
146
+ /** Write every operation in one scope mutation, reporting per-field success. */
147
+ mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
+ }
149
+
150
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
151
+ export interface NumberConstraints {
152
+ /** The accepted value must be a whole number. */
153
+ integer?: boolean
154
+ /** The accepted value must be at least this. */
155
+ min?: number
156
+ }
157
+
158
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
159
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
160
+ const { integer = false, min } = constraints
95
161
  return {
96
162
  field,
97
163
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +165,10 @@ export function numberField(field: string): FieldSpec {
99
165
  const trimmed = text.trim()
100
166
  if (trimmed === '') return { kind: 'clear' }
101
167
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
168
+ if (!Number.isFinite(parsed)) return undefined
169
+ if (integer && !Number.isInteger(parsed)) return undefined
170
+ if (min !== undefined && parsed < min) return undefined
171
+ return { kind: 'set', value: parsed }
103
172
  },
104
173
  }
105
174
  }
@@ -116,19 +185,43 @@ export function textField(field: string): FieldSpec {
116
185
  }
117
186
  }
118
187
 
188
+ /**
189
+ * A free-text field the Host treats as a secret and redacts from the read-back
190
+ * (role('secret') in the section schema). The card still edits it like text,
191
+ * but a save never compares the redacted value back and relies on the scope
192
+ * reporting the write landed.
193
+ */
194
+ export function secretField(field: string): FieldSpec {
195
+ return { ...textField(field), secret: true }
196
+ }
197
+
119
198
  /** A boolean field, edited through true/false draft text. */
120
199
  export function booleanField(field: string): FieldSpec {
121
200
  return {
122
201
  field,
123
202
  format: value => typeof value === 'boolean' ? String(value) : '',
124
203
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
204
+ const trimmed = text.trim()
205
+ if (trimmed === '') return { kind: 'clear' }
206
+ if (trimmed === 'true') return { kind: 'set', value: true }
207
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
208
  return undefined
128
209
  },
129
210
  }
130
211
  }
131
212
 
213
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
214
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
215
+ return {
216
+ field,
217
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
218
+ parse: (text) => {
219
+ if (text === '') return { kind: 'clear' }
220
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
221
+ },
222
+ }
223
+ }
224
+
132
225
  /**
133
226
  * Stages one card's edits over one settings namespace and writes them on save.
134
227
  *
@@ -143,6 +236,7 @@ export class CardForm<T> {
143
236
  private readonly listeners = new Set<() => void>()
144
237
  private saving = false
145
238
  private failed = false
239
+ private failedReason: string | undefined
146
240
 
147
241
  /** @param scope - the bound settings scope for this card's namespace. */
148
242
  constructor(
@@ -172,6 +266,7 @@ export class CardForm<T> {
172
266
  invalid: plan.some(item => item.run === undefined),
173
267
  saving: this.saving,
174
268
  failed: this.failed,
269
+ ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
175
270
  }
176
271
  }
177
272
 
@@ -202,6 +297,7 @@ export class CardForm<T> {
202
297
  if (this.staged.size === 0 && !this.failed) return
203
298
  this.staged.clear()
204
299
  this.failed = false
300
+ this.failedReason = undefined
205
301
  this.publish()
206
302
  },
207
303
  }
@@ -209,25 +305,57 @@ export class CardForm<T> {
209
305
 
210
306
  /**
211
307
  * Write every staged edit, then re-seed from what the Host accepted.
308
+ *
309
+ * When the scope carries the optional batch surface (the dsh-web-ui
310
+ * bridge scope), every planned write rides one mutation so cross-field
311
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
312
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
313
+ * A field lands only when the Host reports it held the staged value; a
314
+ * landed field's draft is dropped, a failed one stays staged for the user.
212
315
  * @returns settlement after every write and the read-back.
213
316
  */
214
317
  async save(): Promise<void> {
215
318
  const plan = this.plan()
216
- const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
319
+ const valid = plan.filter(item => item.run !== undefined)
320
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return
321
+ const plannedWrites = valid.map(item => item.op)
322
+ // Snapshot the fields this save writes, so edits staged while it is in
323
+ // flight survive: only the staged keys this save actually wrote are cleared.
324
+ const fields = new Set(plan.map(item => item.field))
218
325
  this.saving = true
219
326
  this.failed = false
327
+ this.failedReason = undefined
220
328
  this.publish()
221
- let landed = true
222
- for (const write of writes) {
223
- landed = await write() && landed
329
+ const landed = new Set<string>()
330
+ const batch = this.batchedScope()
331
+ if (batch !== undefined) {
332
+ const result = await batch.mutate(plannedWrites)
333
+ if (result.ok) {
334
+ for (const field of result.fields) {
335
+ if (field.landed) landed.add(field.field)
336
+ }
337
+ } else {
338
+ this.failedReason = result.message
339
+ }
340
+ } else {
341
+ for (const item of valid) {
342
+ if (await item.run!()) landed.add(item.field)
343
+ }
344
+ }
345
+ for (const field of fields) {
346
+ if (landed.has(field)) this.staged.delete(field)
224
347
  }
225
- if (landed) this.staged.clear()
226
348
  this.saving = false
227
- this.failed = !landed
349
+ this.failed = landed.size !== fields.size
228
350
  this.publish()
229
351
  }
230
352
 
353
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
354
+ private batchedScope(): BatchedSettingsScope | undefined {
355
+ const candidate = this.scope as unknown as BatchedSettingsScope | undefined
356
+ return typeof candidate?.mutate === 'function' ? candidate : undefined
357
+ }
358
+
231
359
  /**
232
360
  * Every staged edit a save would write. An entry whose draft is not a value
233
361
  * its field accepts carries no write: the form is still dirty, and the save
@@ -240,14 +368,14 @@ export class CardForm<T> {
240
368
  for (const [field, staged] of this.staged) {
241
369
  const spec = this.specOf(field)
242
370
  if (staged.clear) {
243
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
371
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
244
372
  continue
245
373
  }
246
374
  if (staged.text === spec.format(this.sectionValue(field))) continue
247
375
  const write = spec.parse(staged.text)
248
- if (write === undefined) plan.push({ field, run: undefined })
249
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
250
- else plan.push({ field, run: () => this.store(field, write.value) })
376
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
377
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
378
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
251
379
  }
252
380
  return plan
253
381
  }
@@ -259,12 +387,18 @@ export class CardForm<T> {
259
387
 
260
388
  private async store(field: string, value: unknown): Promise<boolean> {
261
389
  await this.scope.set(field, value)
390
+ // A redacted secret never appears in the user layer read-back; judging it
391
+ // by value would misreport a successful secret save as failed. The bridge
392
+ // reports secret writes through its secret-set markers (batch path); on
393
+ // the per-field path the scope resolved, so the write is landed.
394
+ if (this.specOf(field).secret) return true
262
395
  return this.userLayer()?.[field] === value
263
396
  }
264
397
 
265
398
  private stage(field: string, edit: StagedEdit): void {
266
399
  this.staged.set(field, edit)
267
400
  this.failed = false
401
+ this.failedReason = undefined
268
402
  this.publish()
269
403
  }
270
404
 
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  fetchLatestVersion,
28
28
  resolveAnchorManifest,
29
29
  resolveUpdateTarget,
30
- runUpdate,
30
+ runUpdateVerified,
31
31
  type UpdateRunResult,
32
32
  } from './update.ts'
33
33
  import { makeUpdateRoutes } from './update-routes.ts'
@@ -238,19 +238,26 @@ export function apply(ctx: Context, config?: Config): void {
238
238
  }
239
239
  // ── remote update ────────────────────────────────────────────────────────
240
240
  // The dsh-web-ui self-update surface: probe the npm registry for family
241
- // releases and run `pnpm update` in the owning profile. Resolutions anchor
242
- // on the host process's own module graph, so the update always targets the
243
- // profile the running web GUI was booted from. The probe path resolves once
244
- // (the anchor stays the same package across updates); versions are re-read
245
- // from disk per check.
241
+ // releases and run `pnpm update --latest` in the owning profile. Resolutions
242
+ // anchor on the host process's own module graph, so the update always
243
+ // targets the profile the running web GUI was booted from. The anchor path
244
+ // is re-resolved per operation: pnpm removes the old version's .pnpm
245
+ // directory on update, so a boot-time captured path would fail to read
246
+ // after a successful update; versions are re-read from disk per check.
246
247
  const requireFromHost = createRequire(import.meta.url)
247
- const anchorManifestPath = resolveAnchorManifest(specifier => requireFromHost.resolve(specifier))
248
+ const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(specifier => {
249
+ try {
250
+ return requireFromHost.resolve(specifier)
251
+ } catch {
252
+ return undefined
253
+ }
254
+ })
248
255
  const updateRoutes = makeUpdateRoutes({
249
256
  // Control endpoints are host-surface only: a LAN/phone origin must never
250
257
  // trigger a real install on this machine.
251
258
  fence: request => isTrustedApiRequest(request, []),
252
259
  check: () => checkUpdates({
253
- anchorManifestPath,
260
+ anchorManifestPath: resolveAnchorPath(),
254
261
  resolve: specifier => {
255
262
  try {
256
263
  return requireFromHost.resolve(specifier)
@@ -261,7 +268,7 @@ export function apply(ctx: Context, config?: Config): void {
261
268
  fetchLatest: name => fetchLatestVersion(name, fetch),
262
269
  }),
263
270
  run: async (): Promise<UpdateRunResult> => {
264
- const target = resolveUpdateTarget({ anchorManifestPath })
271
+ const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
265
272
  if ('error' in target) {
266
273
  const code = target.error
267
274
  return {
@@ -272,7 +279,24 @@ export function apply(ctx: Context, config?: Config): void {
272
279
  errorCode: code,
273
280
  }
274
281
  }
275
- return runUpdate({ profileDir: target.profileDir, packages: target.packages })
282
+ // Verify the versions actually moved after a green pnpm exit: the pnpm
283
+ // 11 minimumReleaseAge gate can silently keep the installed versions
284
+ // (same-day releases), which a plain exit-0 check would report as
285
+ // success — the user then restarts and nothing changed.
286
+ return runUpdateVerified({
287
+ run: { profileDir: target.profileDir, packages: target.packages },
288
+ check: {
289
+ anchorManifestPath: resolveAnchorPath(),
290
+ resolve: specifier => {
291
+ try {
292
+ return requireFromHost.resolve(specifier)
293
+ } catch {
294
+ return undefined
295
+ }
296
+ },
297
+ fetchLatest: name => fetchLatestVersion(name, fetch),
298
+ },
299
+ })
276
300
  },
277
301
  })
278
302
  const routes = [
@@ -80,8 +80,9 @@ describe('MuxClient polling fallback', () => {
80
80
  await vi.advanceTimersByTimeAsync(400)
81
81
  expect(pollLatest).not.toHaveBeenCalled()
82
82
 
83
- // Past the stall threshold the first poll runs and emits seq 0.
84
- await vi.advanceTimersByTimeAsync(700) // 1100ms total -> first stall-checker tick
83
+ // The single tick (400 ms) crosses the 800 ms stall threshold at 1200 ms:
84
+ // the same tick then runs the first poll and emits seq 0.
85
+ await vi.advanceTimersByTimeAsync(800) // 1200ms total -> first stall crossing tick
85
86
  expect(pollLatest).toHaveBeenCalledWith('s1')
86
87
  expect(frames).toHaveLength(1)
87
88
  expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
@@ -104,7 +105,7 @@ describe('MuxClient polling fallback', () => {
104
105
  client.start()
105
106
  client.observe('s1')
106
107
 
107
- await vi.advanceTimersByTimeAsync(1100) // first poll -> 3 events
108
+ await vi.advanceTimersByTimeAsync(1200) // first poll -> 3 events
108
109
  expect(frames).toHaveLength(3)
109
110
 
110
111
  await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
@@ -119,7 +120,7 @@ describe('MuxClient polling fallback', () => {
119
120
  client.start()
120
121
  client.observe('s1')
121
122
 
122
- await vi.advanceTimersByTimeAsync(1100)
123
+ await vi.advanceTimersByTimeAsync(1200)
123
124
  expect(pollLatest).toHaveBeenCalled()
124
125
 
125
126
  client.observe(undefined)
@@ -136,7 +137,7 @@ describe('MuxClient polling fallback', () => {
136
137
  client.start()
137
138
  client.observe('s1')
138
139
 
139
- await vi.advanceTimersByTimeAsync(1100)
140
+ await vi.advanceTimersByTimeAsync(1200)
140
141
  expect(pollLatest).toHaveBeenCalled()
141
142
 
142
143
  client.stop()
@@ -156,7 +157,7 @@ describe('MuxClient polling fallback', () => {
156
157
  client.observe('s1')
157
158
 
158
159
  // Stall into polling.
159
- await vi.advanceTimersByTimeAsync(1100)
160
+ await vi.advanceTimersByTimeAsync(1200)
160
161
  expect(pollLatest).toHaveBeenCalledTimes(1)
161
162
 
162
163
  // A live mux frame proves SSE delivers again -> fallback stops.
@@ -169,4 +170,81 @@ describe('MuxClient polling fallback', () => {
169
170
  expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
170
171
  client.stop()
171
172
  })
173
+
174
+ it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
175
+ const { factory } = makeSources()
176
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
177
+ try {
178
+ const pages = [pageOf([0]), pageOf([0, 1])]
179
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
180
+ const client = new MuxClient('/m/api/events.mux', {
181
+ sourceFactory: factory,
182
+ pollLatest,
183
+ stallThresholdMs: 1500,
184
+ pollIntervalMs: 2000,
185
+ })
186
+ client.start()
187
+ client.observe('s1')
188
+
189
+ // Exactly one interval is ever created: the single tick scheduler.
190
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1)
191
+
192
+ // The tick fires below the stall threshold without polling.
193
+ await vi.advanceTimersByTimeAsync(1000)
194
+ expect(pollLatest).not.toHaveBeenCalled()
195
+
196
+ // The same interval arms the fallback once silence passes the threshold
197
+ // (tick at 2000 crosses 1500) and runs the first poll immediately.
198
+ await vi.advanceTimersByTimeAsync(1100) // 2100ms total
199
+ expect(pollLatest).toHaveBeenCalledTimes(1)
200
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1) // still one timer
201
+
202
+ // Subsequent polls ride the same tick at the poll cadence (2000 ms).
203
+ await vi.advanceTimersByTimeAsync(2000)
204
+ expect(pollLatest).toHaveBeenCalledTimes(2)
205
+ client.stop()
206
+ } finally {
207
+ setIntervalSpy.mockRestore()
208
+ }
209
+ })
210
+
211
+ it('paces polls on the tick only after the stall phase ends', async () => {
212
+ const { factory } = makeSources()
213
+ const pages = [pageOf([0]), pageOf([0, 1]), pageOf([0, 1, 2]), pageOf([0, 1, 2, 3])]
214
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
215
+ const client = new MuxClient('/m/api/events.mux', {
216
+ sourceFactory: factory,
217
+ pollLatest,
218
+ stallThresholdMs: 800,
219
+ pollIntervalMs: 400,
220
+ })
221
+ client.start()
222
+ client.observe('s1')
223
+
224
+ // At 1200 ms the stall threshold is crossed on this single 400 ms tick.
225
+ await vi.advanceTimersByTimeAsync(1200)
226
+ expect(pollLatest).toHaveBeenCalledTimes(1)
227
+
228
+ // Each subsequent 400 ms tick is a poll, matching the poll cadence.
229
+ await vi.advanceTimersByTimeAsync(400)
230
+ await vi.advanceTimersByTimeAsync(400)
231
+ await vi.advanceTimersByTimeAsync(400)
232
+ expect(pollLatest).toHaveBeenCalledTimes(4)
233
+ client.stop()
234
+ })
235
+
236
+ it('observe() already in the stall window starts the single-tick poller immediately', async () => {
237
+ const { factory } = makeSources()
238
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
239
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
240
+ client.start()
241
+ // Advance well past the stall threshold while nothing is observed.
242
+ await vi.advanceTimersByTimeAsync(2000)
243
+ expect(pollLatest).not.toHaveBeenCalled()
244
+
245
+ // Observing a session now is already in the stall window: poll right away.
246
+ client.observe('s1')
247
+ expect(pollLatest).toHaveBeenCalledTimes(1)
248
+ client.stop()
249
+ })
172
250
  })
package/src/mobile/mux.ts CHANGED
@@ -66,6 +66,12 @@ type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
66
66
 
67
67
  const DEFAULT_POLL_INTERVAL_MS = 3000
68
68
  const DEFAULT_STALL_THRESHOLD_MS = 12000
69
+ /**
70
+ * Stall-check granularity: the single scheduler tick runs at least this
71
+ * often so fallback arms within a second of the stall threshold passing,
72
+ * while the poll cadence itself stays {@link MuxClientOptions.pollIntervalMs}.
73
+ */
74
+ const STALL_CHECK_MS = 1000
69
75
  /** Poll window: enough recent events to cover a few seconds of agent output. */
70
76
  const DEFAULT_POLL_PAGE_SIZE = 50
71
77
 
@@ -98,9 +104,11 @@ export class MuxClient {
98
104
  private sseAlive = false
99
105
  /** Per-session highest event seq already emitted, for poll dedup. */
100
106
  private readonly pollWatermark = new Map<string, number>()
101
- private stallTimer: ReturnType<typeof setInterval> | undefined
102
- private pollTimer: ReturnType<typeof setInterval> | undefined
107
+ /** Single scheduler tick: both the stall check and the poll cadence ride this one interval. */
108
+ private tickTimer: ReturnType<typeof setInterval> | undefined
103
109
  private polling = false
110
+ /** Epoch ms of the next due poll while polling (kept on the same tick timer). */
111
+ private nextPollAt = 0
104
112
 
105
113
  /**
106
114
  * @param url - the mobile events endpoint (browser-relative).
@@ -120,16 +128,17 @@ export class MuxClient {
120
128
  this.stopped = false
121
129
  this.lastDataAt = this.now()
122
130
  if (this.source === undefined) this.connect()
123
- this.startStallChecker()
131
+ this.startTick()
124
132
  }
125
133
 
126
134
  /** Close for good. */
127
135
  stop(): void {
128
136
  this.stopped = true
129
- this.stopStallChecker()
137
+ this.stopTick()
130
138
  this.stopPolling()
131
139
  this.closeSource()
132
140
  this.observeSessionId = undefined
141
+ this.nextPollAt = 0
133
142
  }
134
143
 
135
144
  /** Subscribe to validated frames; returns an unsubscribe function. */
@@ -176,40 +185,52 @@ export class MuxClient {
176
185
  }
177
186
  }
178
187
 
179
- private startStallChecker(): void {
180
- this.stopStallChecker()
181
- this.stallTimer = setInterval(() => {
182
- if (this.stopped) return
183
- if (this.observeSessionId === undefined) return
184
- if (this.polling) return
185
- // A live SSE channel only goes silent while the agent idles; never
186
- // poll against it. Fallback arms again only via onerror or a stream
187
- // that has never delivered.
188
- if (this.sseAlive) return
189
- if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
190
- }, 1000)
188
+ /**
189
+ * The single scheduler tick is the only interval this client owns. One
190
+ * timer (at the finer of the stall-check and poll cadences) both arms the
191
+ * polling fallback once the stall threshold passes and drives each poll at
192
+ * {@link MuxClientOptions.pollIntervalMs} — one timer instead of two.
193
+ */
194
+ private startTick(): void {
195
+ if (this.tickTimer !== undefined) return
196
+ const cadence = Math.min(this.pollIntervalMs, STALL_CHECK_MS)
197
+ this.tickTimer = setInterval(() => { this.tick() }, cadence)
198
+ }
199
+
200
+ private stopTick(): void {
201
+ if (this.tickTimer !== undefined) {
202
+ clearInterval(this.tickTimer)
203
+ this.tickTimer = undefined
204
+ }
191
205
  }
192
206
 
193
- private stopStallChecker(): void {
194
- if (this.stallTimer !== undefined) {
195
- clearInterval(this.stallTimer)
196
- this.stallTimer = undefined
207
+ private tick(): void {
208
+ if (this.stopped) return
209
+ if (this.observeSessionId === undefined) return
210
+ if (this.polling) {
211
+ // The stall phase has ended; the same tick now paces the polls.
212
+ if (this.now() >= this.nextPollAt) {
213
+ this.nextPollAt = this.now() + this.pollIntervalMs
214
+ void this.pollTick()
215
+ }
216
+ return
197
217
  }
218
+ // A live SSE channel only goes silent while the agent idles; never poll
219
+ // against it. Fallback arms again only via onerror or a stream that has
220
+ // never delivered.
221
+ if (this.sseAlive) return
222
+ if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
198
223
  }
199
224
 
200
225
  private startPolling(): void {
201
226
  if (this.polling || this.stopped) return
202
227
  this.polling = true
228
+ this.nextPollAt = this.now() + this.pollIntervalMs
203
229
  void this.pollTick()
204
- this.pollTimer = setInterval(() => { void this.pollTick() }, this.pollIntervalMs)
205
230
  }
206
231
 
207
232
  private stopPolling(): void {
208
233
  this.polling = false
209
- if (this.pollTimer !== undefined) {
210
- clearInterval(this.pollTimer)
211
- this.pollTimer = undefined
212
- }
213
234
  }
214
235
 
215
236
  /**
package/src/mobile-api.ts CHANGED
@@ -49,6 +49,8 @@ const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
49
49
 
50
50
  /** One session.list page (thin phones load incrementally). */
51
51
  const SESSION_PAGE_SIZE = 20
52
+ /** SSE keep-alive ping cadence for the live mux stream (single connection). */
53
+ const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000
52
54
 
53
55
  /** Encode one list position as an opaque continuation cursor. */
54
56
  function sessionListCursor(updatedAt: number, sessionId: string): string {
@@ -78,6 +80,8 @@ export interface MobileApiDeps {
78
80
  apiProxy: ApiProxy
79
81
  /** The resolved mobile composer preference (live per request). */
80
82
  mobileEnterToSend: () => boolean
83
+ /** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
84
+ eventsHeartbeatMs?: number
81
85
  }
82
86
 
83
87
  /** Mobile API route paths. */
@@ -97,6 +101,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
97
101
  */
98
102
  export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
99
103
  const { service, apiProxy, mobileEnterToSend } = deps
104
+ const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS
100
105
 
101
106
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
102
107
  const gateOk = (req: IncomingMessage): boolean => {
@@ -190,7 +195,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
190
195
  } catch {
191
196
  // The write failed; the close handler tears the subscription down.
192
197
  }
193
- }, 15_000)
198
+ }, eventsHeartbeatMs)
194
199
  const onClose = (): void => {
195
200
  if (closed) return
196
201
  closed = true