dsh-side-chat-plus 0.4.0-alpha.2 → 0.4.1

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/src/index.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * dsh-side-chat host half: the /sidechat JSON API. Every side chat is an
3
3
  * ORDINARY session (no `origin: 'subagent'`) whose `meta.parentSession` points
4
- * at the conversation that launched it, archived immediately so it appears in
5
- * neither the main session list nor the subagent catalog, and driven directly
6
- * through the live agent (followup). Model / reasoning-effort / permission are
4
+ * at the conversation that launched it, kept archived so it appears in neither
5
+ * the main session list nor the subagent catalog, and driven directly through
6
+ * the live agent (followup). Model / reasoning-effort / permission are
7
7
  * inherited from the launching conversation at creation and adjustable later.
8
8
  *
9
+ * DSH 0.1.7 admits archived sessions to no model step: its archived-session
10
+ * gate answers `{ kind: 'reject' }` on `agent/pre-step` for a session in the
11
+ * registry archive set, so a turn ends `blocked` before the first request. The
12
+ * archive is therefore lifted (unarchived) for exactly as long as a side chat
13
+ * has work queued and restored as soon as its agent settles — see
14
+ * `deliver`/`settleTurn` below.
15
+ *
9
16
  * All routes pass the same browser-trust fence as the /api gateway (loopback
10
17
  * or trusted authority; cross-site markers refuse).
11
18
  */
@@ -14,7 +21,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
14
21
  import { dirname } from 'node:path'
15
22
  import * as agentApi from '@deepseek-ai/dsh-agent'
16
23
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
17
- import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
24
+ import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
18
25
  import z from 'schemastery'
19
26
  import type {
20
27
  Context,
@@ -27,7 +34,7 @@ import type {
27
34
  SideSession,
28
35
  SideSessionEvent,
29
36
  } from './context-types.ts'
30
- import { SUBCHAT_PREFS_DEFAULTS, SUBCHAT_PREFS_NS, type SubchatPrefs } from './settings-shared.ts'
37
+ import { SUBCHAT_PREFS_DEFAULTS, type SubchatPrefs } from './settings-shared.ts'
31
38
  import { isTrustedApiRequest } from './trust-fence.ts'
32
39
  import { optionalBoolean, readJsonBody, requireString, SidechatError, writeError, writeJson, writeOk } from './wire.ts'
33
40
 
@@ -64,6 +71,12 @@ interface SidechatRecord {
64
71
  /** Live selection coupled to the agent; mutated by `sidechat.selectModel`. */
65
72
  selection: SideModelSelectionRef
66
73
  createdAt: number
74
+ /**
75
+ * Deliveries whose turn has not settled yet. The session stays unarchived
76
+ * while this is non-zero and is archived again when it reaches zero, so a
77
+ * followup sent mid-turn can never be archived out from under itself.
78
+ */
79
+ pendingTurns: number
67
80
  }
68
81
 
69
82
  /** Durable record list file (cleanup is a later feature; the list is the record). */
@@ -190,6 +203,10 @@ const PrefsSchema: z<SubchatPrefs> = z.object({
190
203
  panelHome: z.union(['floating', 'sidebar-right']).default(SUBCHAT_PREFS_DEFAULTS.panelHome),
191
204
  })
192
205
 
206
+ // DSH 0.1.7 derives settings from the Loader entry's live Config fields.
207
+ // Volatile preferences update without disposing the running side chats.
208
+ export const Config = PrefsSchema.volatile()
209
+
193
210
  /** Live settings face (bound when the settings service is mounted). */
194
211
  interface SubchatSettingsFace {
195
212
  get(): { value?: unknown; revision?: number }
@@ -361,7 +378,68 @@ function buildApi(ctx: Context, sideChats: Map<string, SidechatRecord>, getSetti
361
378
  }
362
379
  }
363
380
 
364
- /** Create one side chat: inherit, archive, record, deliver the first prompt. */
381
+ /**
382
+ * Serialize one side chat's archive/unarchive writes. Both registry calls go
383
+ * through the host registry's own operation queue, so enqueueing them in call
384
+ * order is what decides the final state; this chain keeps that order stable
385
+ * when a settling turn and a new delivery overlap.
386
+ */
387
+ const visibilityQueue = new Map<string, Promise<void>>()
388
+ const queueVisibility = (childId: string, op: () => Promise<void>): Promise<void> => {
389
+ const previous = visibilityQueue.get(childId) ?? Promise.resolve()
390
+ const next = previous.then(op, op)
391
+ visibilityQueue.set(childId, next.then(() => {}, () => {}))
392
+ return next
393
+ }
394
+
395
+ /**
396
+ * Restore one side chat's session before a turn. Registries older than the
397
+ * 0.1.7 gate have no `unarchiveSession` (and need none): there the archive
398
+ * simply stays in place for the whole turn. A failed restore propagates: the
399
+ * caller must not deliver a prompt the gate will answer with `blocked`.
400
+ */
401
+ const unarchiveChild = (childId: string): Promise<void> => {
402
+ const unarchive = ctx.workspaceRegistry.unarchiveSession
403
+ if (unarchive === undefined) return archiveChild(childId)
404
+ return queueVisibility(childId, () => unarchive.call(ctx.workspaceRegistry, childId))
405
+ }
406
+
407
+ /**
408
+ * Hide one side chat's session again once its agent has settled. Best-effort:
409
+ * a registry that reports the session active (or refuses the write) only
410
+ * leaves it visible until the next settle, never breaks a turn.
411
+ */
412
+ const archiveChild = (childId: string): Promise<void> => {
413
+ return queueVisibility(childId, () => ctx.workspaceRegistry.archiveSession(childId)).catch(() => {})
414
+ }
415
+
416
+ /** Count one settled turn and re-archive the session when the last one ends. */
417
+ const settleTurn = (record: SidechatRecord): void => {
418
+ if (record.pendingTurns > 0) record.pendingTurns -= 1
419
+ if (record.pendingTurns > 0) return
420
+ void archiveChild(record.childId)
421
+ }
422
+
423
+ /**
424
+ * Hand one prompt to a side chat: lift the archive, deliver, and let the
425
+ * agent's own idle boundary put the session back out of every list. `followup`
426
+ * opens the driver synchronously (the phase turns `running` before it
427
+ * returns), so observing `whenIdle` right after it can never miss the turn.
428
+ */
429
+ const deliver = async (record: SidechatRecord, message: unknown): Promise<void> => {
430
+ record.pendingTurns += 1
431
+ try {
432
+ await unarchiveChild(record.childId)
433
+ record.handle.agent.followup(message)
434
+ } catch (error) {
435
+ // Balance the count: the delivery never opened a turn.
436
+ settleTurn(record)
437
+ throw error
438
+ }
439
+ void record.handle.agent.whenIdle().then(() => { settleTurn(record) }, () => { settleTurn(record) })
440
+ }
441
+
442
+ /** Create one side chat: inherit, record, deliver the first prompt (hidden while idle). */
365
443
  const start = async (payload: unknown): Promise<{ childId: string; provider: string; model: string; reasoningEffort?: string }> => {
366
444
  const parentSessionId = requireString(payload, 'parentSessionId')
367
445
  const content = requireContent(payload)
@@ -450,19 +528,18 @@ function buildApi(ctx: Context, sideChats: Map<string, SidechatRecord>, getSetti
450
528
  console.warn('[dsh-side-chat] permission inherit failed:', error instanceof Error ? error.message : String(error))
451
529
  }
452
530
 
453
- // Archive BEFORE the first prompt so the session is hidden from every list.
454
- await ctx.workspaceRegistry.archiveSession(childId)
455
-
456
- sideChats.set(childId, {
531
+ const child: SidechatRecord = {
457
532
  childId,
458
533
  parentSessionId,
459
534
  handle,
460
535
  selection,
461
536
  createdAt: Date.now(),
462
- })
537
+ pendingTurns: 0,
538
+ }
539
+ sideChats.set(childId, child)
463
540
  persist()
464
541
 
465
- handle.agent.followup(userMessage(await durableContent(content, lookupEnabled)))
542
+ await deliver(child, userMessage(await durableContent(content, lookupEnabled)))
466
543
  return { childId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
467
544
  }
468
545
 
@@ -472,7 +549,17 @@ function buildApi(ctx: Context, sideChats: Map<string, SidechatRecord>, getSetti
472
549
  const content = requireContent(payload)
473
550
  const lookupEnabled = optionalBoolean(payload, 'lookupEnabled')
474
551
  const child = childOf(childId)
475
- child.followup(userMessage(await durableContent(content, lookupEnabled)))
552
+ const record = sideChats.get(childId)
553
+ const message = userMessage(await durableContent(content, lookupEnabled))
554
+ if (record === undefined) {
555
+ // A child adopted from the live agent registry (record lost) still needs
556
+ // its archive lifted before a turn it did not create itself.
557
+ await unarchiveChild(childId)
558
+ child.followup(message)
559
+ void child.whenIdle().then(() => { void archiveChild(childId) }, () => { void archiveChild(childId) })
560
+ return { accepted: true }
561
+ }
562
+ await deliver(record, message)
476
563
  return { accepted: true }
477
564
  }
478
565
 
@@ -651,7 +738,9 @@ function buildApi(ctx: Context, sideChats: Map<string, SidechatRecord>, getSetti
651
738
  id: randomUUID(),
652
739
  role: 'user',
653
740
  content: [{ type: 'text', text }],
654
- source: { kind: 'plugin', plugin: 'dsh-side-chat', form: 'notice', summary },
741
+ // V4 rejects the retired { kind: 'plugin', plugin: ... } wrapper.
742
+ // Match the identity assigned to historical messages by V3→V4 migration.
743
+ source: { kind: 'plugin:dsh-side-chat', form: 'notice', summary },
655
744
  })
656
745
  return { accepted: true }
657
746
  }
@@ -810,15 +899,11 @@ export function apply(ctx: Context): void {
810
899
  const sideChats = new Map<string, SidechatRecord>()
811
900
  let settingsFace: SubchatSettingsFace | undefined
812
901
 
813
- // Register the preferences namespace with the (optional) settings service.
814
- // The client reads/writes it through the plugin's own fenced routes, since
815
- // the DSH settings RPC domain only serves allowlisted namespaces.
902
+ // Settings namespaces are now Loader entry ids, including user-renamed rows.
816
903
  ctx.inject(['settings'], (sctx: Context) => {
817
- const ns = SUBCHAT_PREFS_NS as SettingsNamespace
818
- const scope = sctx.settings.register(ns, PrefsSchema) as {
819
- get(): SubchatPrefs
820
- watch(cb: (next: SubchatPrefs, prev: SubchatPrefs) => void): () => void
821
- }
904
+ const ns = (ctx as Context & { entry?: { id: string } }).entry?.id
905
+ if (ns === undefined) return
906
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
822
907
  const viewOf = (): { value?: unknown; revision?: number } => {
823
908
  const descriptor = sctx.settings.describe({ redactSecrets: true }).find((c) => c.ns === ns)
824
909
  return descriptor === undefined
@@ -832,26 +917,41 @@ export function apply(ctx: Context): void {
832
917
  return viewOf()
833
918
  },
834
919
  }
835
- void scope
920
+ sctx.effect(() => () => { settingsFace = undefined })
836
921
  })
837
922
 
838
923
  const api = buildApi(ctx, sideChats, () => settingsFace)
839
924
 
840
- // Tear every live side chat down with the plugin fiber.
925
+ // Tear every live side chat down with the plugin fiber, and put back the
926
+ // archive of any chat that was mid-turn (a live turn is lifted from the
927
+ // archive for its duration, so an unload would otherwise leave it visible).
841
928
  ctx.effect(() => {
842
929
  return () => {
843
- for (const record of sideChats.values()) {
844
- void record.handle.dispose().catch(() => {})
845
- }
930
+ const records = [...sideChats.values()]
846
931
  sideChats.clear()
932
+ void (async () => {
933
+ for (const record of records) {
934
+ try {
935
+ await record.handle.dispose()
936
+ } catch {
937
+ // A half-disposed agent must not stop the archive restore below.
938
+ }
939
+ try {
940
+ await ctx.workspaceRegistry.archiveSession(record.childId)
941
+ } catch {
942
+ // Best-effort: a still-active session stays visible until next run.
943
+ }
944
+ }
945
+ })()
847
946
  }
848
947
  }, 'dsh-side-chat: dispose side chats')
849
948
 
850
949
  // DSH 0.1.6 lists archived sessions in Settings and lets the user restore
851
950
  // one. A side chat is archived precisely so it stays out of the session
852
- // list, so at activation re-archive every recorded side chat that a restore
853
- // (or an older run) left visible again. Best-effort: records whose session
854
- // no longer exists are simply skipped.
951
+ // list, so at activation re-archive every recorded side chat that a restore,
952
+ // an interrupted run (turn still open when the harness stopped), or an older
953
+ // run left visible again. Best-effort: records whose session no longer
954
+ // exists are simply skipped.
855
955
  ctx.effect(() => {
856
956
  void (async () => {
857
957
  for (const record of await readRecords()) {
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Shared side-chat preference vocabulary (types + constants), consumed by
3
- * BOTH halves: the host registers the schemastery schema over these values
3
+ * BOTH halves: the host exports a live Config schema over these values
4
4
  * (index.ts) and the client reads/writes them through the plugin's own
5
5
  * fenced /sidechat settings routes. Kept free of schemastery so the browser
6
6
  * bundle never pulls the schema runtime in.
7
7
  */
8
8
 
9
- /** The user-settings namespace holding the side-chat preferences. */
10
- export const SUBCHAT_PREFS_NS = 'dsh-side-chat'
11
-
12
9
  /** How a brought-back reply lands in the main conversation. */
13
10
  export type BringMode = 'draft' | 'context'
14
11