dsh-zen-remote 2.3.5 → 2.3.6

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 (48) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/interface.md +2 -2
  4. package/docs/plugin-bridge.md +2 -2
  5. package/docs/remote-access.en.md +3 -3
  6. package/docs/remote-access.md +3 -3
  7. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +84 -19
  10. package/lib/index.js +13 -0
  11. package/lib/index.js.map +1 -1
  12. package/lib/intercept.js +43 -1
  13. package/lib/intercept.js.map +1 -1
  14. package/lib/plugin-adapter-core.js +30 -1
  15. package/lib/plugin-adapter-core.js.map +1 -1
  16. package/lib/plugin-route-intercept.js +506 -11
  17. package/lib/plugin-route-intercept.js.map +1 -1
  18. package/lib/plugin-tunnel.js +210 -18
  19. package/lib/plugin-tunnel.js.map +1 -1
  20. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  21. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  22. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  23. package/lib/types/client/locales.d.ts +2 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/styles/content.css.d.ts +1 -1
  26. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  27. package/lib/types/client/styles/layout.css.d.ts +1 -1
  28. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/lib/types/intercept.d.ts +6 -0
  31. package/lib/types/intercept.d.ts.map +1 -1
  32. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  33. package/lib/types/plugin-route-intercept.d.ts +8 -0
  34. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  35. package/lib/types/plugin-tunnel.d.ts +37 -2
  36. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/scripts/check-info-sheet-stacking.mjs +85 -0
  39. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  40. package/src/client/MobileSessionInfo.tsx +29 -2
  41. package/src/client/locales.ts +4 -0
  42. package/src/client/styles/content.css.ts +44 -14
  43. package/src/client/styles/layout.css.ts +9 -3
  44. package/src/index.ts +13 -0
  45. package/src/intercept.ts +46 -0
  46. package/src/plugin-adapter-core.ts +29 -2
  47. package/src/plugin-route-intercept.ts +516 -12
  48. package/src/plugin-tunnel.ts +230 -20
@@ -14,6 +14,9 @@
14
14
  // Run: node scripts/check-info-sheet-stacking.mjs (needs Node >= 23.6 type stripping)
15
15
  import assert from 'node:assert/strict'
16
16
  import { INFO_CSS } from '../src/client/styles/info.css.ts'
17
+ import { CONTENT_CSS } from '../src/client/styles/content.css.ts'
18
+ import { LAYOUT_CSS } from '../src/client/styles/layout.css.ts'
19
+ import { en, zh } from '../src/client/locales.ts'
17
20
 
18
21
  /** Host z-index values this sheet has to out-rank (dsh-client-ui-conversation). */
19
22
  const HOST_COMPOSER_SEAT = 7
@@ -52,4 +55,86 @@ assert.ok(
52
55
  'the header promotion escaped the (max-width: 767px) block — it would change the desktop layout',
53
56
  )
54
57
 
58
+ // ---- UI-1 (2026-10-30): turn-tail clock + info-card stat cells -------------
59
+ // Lives here rather than in its own script because package.json's test line
60
+ // is an explicit list, and the info-card half of UI-1 is this sheet's own
61
+ // content. Both halves regress silently (CSS still parses, the card still
62
+ // opens), so they get the same loud-failure treatment as the stacking rule.
63
+
64
+ /** Body of the single rule whose selector line matches `selector` exactly. */
65
+ const ruleBody = (css, selector, label) => {
66
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
+ const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`).exec(css)
68
+ assert.ok(match, `${label}: rule \`${selector}\` is gone`)
69
+ return match[1]
70
+ }
71
+
72
+ // DSH 0.2.x wraps the usage button and the 24h clock in one non-wrapping
73
+ // inline-flex _endInfo. The 0.1.x-era rule (flex-basis: 100% + white-space:
74
+ // normal on the clock) squeezed "18:06" onto two lines inside it.
75
+ const tailTime = ruleBody(
76
+ CONTENT_CSS,
77
+ '[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"]',
78
+ 'content.css.ts',
79
+ )
80
+ assert.ok(
81
+ /white-space:\s*nowrap\s*!important/.test(tailTime),
82
+ 'turn-tail _timeEnd must declare white-space: nowrap !important — the clock may never break mid-"18:06"',
83
+ )
84
+ assert.ok(
85
+ !/flex-basis:\s*100%/.test(tailTime),
86
+ 'turn-tail _timeEnd still carries flex-basis: 100% — the 0.1.x "own line" clamp that squeezes the clock inside _endInfo',
87
+ )
88
+ assert.ok(
89
+ !/white-space:\s*normal/.test(tailTime),
90
+ 'turn-tail _timeEnd still carries white-space: normal — this is what let the clock wrap',
91
+ )
92
+ assert.ok(
93
+ /flex:\s*none\s*!important/.test(tailTime),
94
+ 'turn-tail _timeEnd must be flex: none — a shrinkable clock is the squeeze itself',
95
+ )
96
+
97
+ // The other half of the root cause: layout.css.ts used to make the clock a
98
+ // shrinkable flex item (flex: 0 1 auto + min-width: 0) for every message row.
99
+ const rowTime = ruleBody(LAYOUT_CSS, '[data-phase] [class$="_actions"] [class$="_timeEnd"]', 'layout.css.ts')
100
+ assert.ok(/flex:\s*none\s*!important/.test(rowTime), 'layout.css.ts _timeEnd must be flex: none !important')
101
+ assert.ok(
102
+ !/min-width:\s*0/.test(rowTime) && !/flex:\s*0 1 auto/.test(rowTime),
103
+ 'layout.css.ts _timeEnd is shrinkable again (min-width: 0 / flex: 0 1 auto) — the clock will be squeezed inside _endInfo',
104
+ )
105
+
106
+ // _endInfo wraps as a unit, and the row may grow instead of clipping.
107
+ const endInfo = ruleBody(
108
+ CONTENT_CSS,
109
+ '[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"]',
110
+ 'content.css.ts',
111
+ )
112
+ assert.ok(/flex:\s*none/.test(endInfo), 'turn-tail _endInfo must be flex: none so the usage + clock group wraps whole')
113
+ const tailRows = [...CONTENT_CSS.matchAll(/\n\s*\[data-chat-flow-kind="turn-tail"\] \[class\$="_actions"\]\s*\{([^}]*)\}/g)]
114
+ const tailRow = tailRows.map((m) => m[1]).find((body) => /flex-wrap/.test(body))
115
+ assert.ok(tailRow, 'content.css.ts lost the wrapping turn-tail _actions rule')
116
+ assert.ok(
117
+ /height:\s*auto\s*!important/.test(tailRow) && /overflow:\s*visible\s*!important/.test(tailRow),
118
+ 'turn-tail _actions must release the host fixed height (height: auto) and layout.css.ts overflow: hidden (overflow: visible), or a wrapped second line is cut off',
119
+ )
120
+
121
+ // Desktop no-op: every turn-tail rule above has to sit inside the phone block.
122
+ const contentPhone = /@media \(max-width: 767px\) \{/.exec(CONTENT_CSS)
123
+ assert.ok(contentPhone, 'content.css.ts lost its phone media query')
124
+ assert.ok(
125
+ CONTENT_CSS.indexOf('[data-chat-flow-kind="turn-tail"]') > contentPhone.index,
126
+ 'a turn-tail rule escaped the (max-width: 767px) block — it would change the desktop layout',
127
+ )
128
+
129
+ // Info-card stat cells: turns + steps merged into one cell, output speed in
130
+ // the freed slot. Both dictionaries must carry the keys or t() prints the key.
131
+ for (const [name, dict] of [['zh', zh], ['en', en]]) {
132
+ for (const key of ['infoStatTurnsSteps', 'infoStatSpeed']) {
133
+ assert.ok(
134
+ typeof dict[key] === 'string' && dict[key].length > 0,
135
+ `locales.ts ${name} is missing '${key}' — the info card would render the raw key as a label`,
136
+ )
137
+ }
138
+ }
139
+
55
140
  console.log('check-info-sheet-stacking: ok')
@@ -15,8 +15,11 @@
15
15
  * model spend (commit-message generation), documented as such; NO path
16
16
  * fencing; `open.external` refuses for a remote session; `settings.*` and
17
17
  * `/sidebar/bundle` stay local; the adapter registers as whole-machine
18
- * data (consent per plugin, default off). PB-15: the 0.26.0 WebSocket
19
- * `/sidebar/ws/sidechat` is deliberately NOT declared (not bridged).
18
+ * data (consent per plugin, default off). PB-15 left the 0.26.0 WebSocket
19
+ * `/sidebar/ws/sidechat` undeclared; PB-26b bridges it — its ids ride the
20
+ * post-handshake JSON frames (`{"op":"watch","threadIds":[...]}`), which
21
+ * the json-body upgrade gate locates, classifies and rewrites frame by
22
+ * frame on both halves.
20
23
  *
21
24
  * The `cwd` handling is the one subtle part: better-sidebar resolves a
22
25
  * request's working directory as the session's own `header.cwd` first and
@@ -147,6 +150,16 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
147
150
  // file paths, not session ids).
148
151
  { path: '/sidebar/ws/agent-opens', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
149
152
  { path: '/sidebar/ws/fs-watch', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
153
+ // The side-chat push feed (PB-26b; lib/index.js's third registerUpgrade):
154
+ // the upgrade URL carries NO query — the browser's first frame after the
155
+ // handshake is `{"op":"watch","threadIds":[...]}` and the plugin pushes
156
+ // `{"threadId":"..."}` per event. The json-body locator rides the FRAME
157
+ // gate: the client half classifies and rewrites every watch frame (an
158
+ // empty list — unsubscribe-all — crosses untouched), the server's send
159
+ // gate share-checks each frame before the plugin sees it, and the
160
+ // client.response hook below virtualizes the pushed threadId so the
161
+ // browser's `wantedRef.includes(frame.threadId)` comparison fires.
162
+ { path: '/sidebar/ws/sidechat', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'json-body', fields: ['threadIds'] } },
150
163
  ],
151
164
  client: {
152
165
  request: (ctx) => {
@@ -160,6 +173,22 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
160
173
  return { allow: true }
161
174
  },
162
175
  response: (ctx) => {
176
+ // PB-26b: the sidechat ws PUSH frames name their thread on the TOP
177
+ // level — `{"threadId":"<real id>"}`. The browser compares
178
+ // `wantedRef.current.includes(frame.threadId)` against the VIRTUAL ids
179
+ // it watched, so a real id here would silently miss every push; the
180
+ // virtualized form rides in its place. A frame with no usable tid (a
181
+ // non-object, or an id that is already virtual) crosses untouched.
182
+ if (ctx.pathname === '/sidebar/ws/sidechat') {
183
+ if (ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body)) {
184
+ const record = ctx.body as Record<string, unknown>
185
+ const tid = record.threadId
186
+ if (typeof tid === 'string' && tid !== '' && !ctx.isVirtual(tid)) {
187
+ return { ...record, threadId: ctx.virtualize(tid) }
188
+ }
189
+ }
190
+ return ctx.body
191
+ }
163
192
  const name = apiMethodOf(ctx.pathname)
164
193
  if (name === '') return ctx.body
165
194
  const spec = SIDEBAR_METHODS[name]
@@ -100,6 +100,17 @@ function formatDuration(ms: number): string {
100
100
  return `${Math.floor(whole / 60)}m${whole % 60}s`
101
101
  }
102
102
 
103
+ /**
104
+ * Decode-throughput figure: whole tokens from ten up, one decimal below.
105
+ * Ported digit-for-digit from dsh-client-ui-chat lib/client.js
106
+ * formatTokensPerSecond (DSH 0.2.x TurnUsagePanel; verified 2026-10-30),
107
+ * which also owns the "{tps} tok/s" unit the cell appends.
108
+ */
109
+ function formatTokensPerSecond(tps: number): string {
110
+ const clamped = Math.max(0, tps)
111
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
112
+ }
113
+
103
114
  /** Sum of the three disjoint prompt-side billing buckets. */
104
115
  function billedInputTokens(usage: { uncachedInputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }): number {
105
116
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
@@ -448,8 +459,24 @@ export function MobileSessionInfo({
448
459
  }
449
460
 
450
461
  const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
451
- { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
452
- { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
462
+ /* Turns and steps share one cell ("3 / 12") so the grid stays 2×3 with
463
+ output speed taking the freed slot (UI-1, 2026-10-30). */
464
+ {
465
+ label: t('infoStatTurnsSteps'),
466
+ value: stats === undefined ? NA : `${stats.turns} / ${stats.steps}`,
467
+ sub: undefined,
468
+ },
469
+ /* Same guard and formula as the official TurnUsagePanel: decodeMs > 0
470
+ is its "we have a throughput reading" test, so a session with no
471
+ usage-reporting step shows the placeholder, never "0 tok/s". */
472
+ {
473
+ label: t('infoStatSpeed'),
474
+ value:
475
+ stats === undefined || stats.decodeMs <= 0
476
+ ? NA
477
+ : `${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`,
478
+ sub: undefined,
479
+ },
453
480
  {
454
481
  label: t('infoStatTtft'),
455
482
  value: stats === undefined || stats.ttftSteps === 0 ? NA : formatDuration(stats.ttftMs / stats.ttftSteps),
@@ -35,6 +35,8 @@ export const zh = {
35
35
  'activityOpen': '查看子代理与后台任务',
36
36
  'infoStatTurns': '轮次',
37
37
  'infoStatSteps': '步骤',
38
+ 'infoStatTurnsSteps': '轮次 / 步数',
39
+ 'infoStatSpeed': '输出速度',
38
40
  'infoStatTtft': '首字延迟',
39
41
  'infoStatLlm': '模型耗时',
40
42
  'infoStatTool': '工具耗时',
@@ -549,6 +551,8 @@ export const en: Record<MobileNavKey, string> = {
549
551
  'activityOpen': 'View subagents and background tasks',
550
552
  'infoStatTurns': 'Turns',
551
553
  'infoStatSteps': 'Steps',
554
+ 'infoStatTurnsSteps': 'Turns / Steps',
555
+ 'infoStatSpeed': 'Output speed',
552
556
  'infoStatTtft': 'TTFT',
553
557
  'infoStatLlm': 'LLM time',
554
558
  'infoStatTool': 'Tool time',
@@ -84,15 +84,25 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
84
84
  max-width: none !important;
85
85
  }
86
86
 
87
- /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
88
- 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
89
- [data-time-hover-root] gate holds them at opacity 0 until :hover /
90
- :focus-within) — a phone never hovers, so they were simply invisible;
91
- and even ignoring that, the row is nowrap + overflow:hidden (plus this
92
- plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
93
- text. On phones: always visible, own full-width line under the action
94
- icons, wrapping freely, one type size down. !important + the extra
95
- [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
87
+ /* Turn-tail metadata (2026-08-18; reshaped 2026-10-30 for DSH 0.2.x): the
88
+ tail's 24h clock is desktop-hover-revealed (official
89
+ [data-time-hover-root] gate holds it at opacity 0 until :hover /
90
+ :focus-within) — a phone never hovers, so it was simply invisible; and
91
+ the row is a fixed-height nowrap + overflow:hidden flex line (plus this
92
+ plugin's own clamp in layout.css.ts), which clipped it.
93
+
94
+ DSH 0.2.x (MessageIconActions, dsh-client-ui-chat) groups the usage
95
+ button (TurnUsagePanel) and the clock (_timeEnd) inside ONE inline-flex
96
+ _endInfo (min-width: 0; align-items: center; gap: 8px; margin-left: 8px)
97
+ that never wraps internally. The 0.1.x-era rule here gave _timeEnd
98
+ flex-basis: 100% + white-space: normal — inside _endInfo that no longer
99
+ reads as "own full-width line", it reads as "shrink to nothing and
100
+ break", and with the transcript-wide overflow-wrap: anywhere above,
101
+ "18:06" rendered as two lines. On phones now: always visible; the row
102
+ may grow in height and wrap; _endInfo drops to the next line as one
103
+ unit when icons + usage + clock do not fit; the clock itself never
104
+ shrinks or breaks. !important + the extra [class$="_actions"] hop
105
+ out-specifies both upstream and layout.css.ts. */
96
106
  /* Pin the tail row's width chain before allowing wrap: with wrap enabled
97
107
  the row's intrinsic (shrink-to-fit) width collapses to its widest
98
108
  single icon, taking the whole tail down to ~16px. */
@@ -100,9 +110,26 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
100
110
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
101
111
  width: 100%;
102
112
  }
113
+ /* height: auto releases the upstream fixed height: calc(28px + …);
114
+ overflow: visible beats layout.css.ts's overflow: hidden (same
115
+ specificity — this file is concatenated last, so it wins the tie). A
116
+ wrapped second line must not be cut off vertically; min-height keeps
117
+ the one-line case at the upstream height so nothing shifts when it
118
+ fits. */
103
119
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
104
120
  flex-wrap: wrap;
105
121
  row-gap: 2px;
122
+ height: auto !important;
123
+ min-height: 28px;
124
+ overflow: visible !important;
125
+ }
126
+ /* _endInfo moves as a whole: flex: none so the usage + clock group is
127
+ never asked to shrink — when the row is too narrow the entire group
128
+ wraps to the next line instead of compressing the clock inside it.
129
+ max-width keeps an unusually long usage label inside the column. */
130
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"] {
131
+ flex: none;
132
+ max-width: 100%;
106
133
  }
107
134
  /* Wide companion to the rule below: mirrors the upstream hover-gate
108
135
  selector shape directly, with no assumptions about the tail's DOM
@@ -113,13 +140,16 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
113
140
  opacity: 1 !important;
114
141
  transition: none !important;
115
142
  }
143
+ /* overflow-wrap / word-break: normal undo the [data-chat-flow]
144
+ overflow-wrap: anywhere inherited from the top of this block — that is
145
+ the rule that let "18:06" break between digits once its box was
146
+ squeezed. */
116
147
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
117
148
  opacity: 1 !important;
118
- flex-basis: 100% !important;
119
- white-space: normal !important;
120
- overflow: visible !important;
121
- text-overflow: clip !important;
122
- padding-left: 0 !important;
149
+ flex: none !important;
150
+ white-space: nowrap !important;
151
+ overflow-wrap: normal !important;
152
+ word-break: normal !important;
123
153
  font-size: 12px;
124
154
  line-height: 18px;
125
155
  }
@@ -139,12 +139,18 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
139
139
  [data-phase] [class$="_actions"] {
140
140
  overflow: hidden !important;
141
141
  }
142
+ /* The 24h clock ("18:06" / "MM-DD 18:06") is a fixed-size item: it must
143
+ never be the thing that shrinks. DSH 0.2.x wraps it with the usage
144
+ button in an inline-flex _endInfo (min-width: 0), so a shrinkable
145
+ time (the old flex: 0 1 auto + min-width: 0) got squeezed inside that
146
+ non-wrapping box and "18:06" broke into two lines. flex: none pins its
147
+ width; the turn-tail rules in content.css.ts let _endInfo wrap as a
148
+ whole instead. */
142
149
  [data-phase] [class$="_actions"] [class$="_timeEnd"] {
143
- flex: 0 1 auto !important;
144
- min-width: 0 !important;
150
+ flex: none !important;
151
+ white-space: nowrap !important;
145
152
  overflow: hidden !important;
146
153
  text-overflow: ellipsis !important;
147
- white-space: nowrap !important;
148
154
  }
149
155
 
150
156
  [data-phase] [class$="_scroll"]:has(p) {
package/src/index.ts CHANGED
@@ -743,6 +743,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
743
743
  // best-effort bookkeeping — it must never be able to fail the
744
744
  // creation that triggered it.
745
745
  try {
746
+ // PB-26a: record the subagent→parent link the moment the child
747
+ // exists — for EVERY source. A sidechat child (better-sidebar's
748
+ // sidechat.start → agents.create) rides no workspace and writes
749
+ // no catalog; until its first session/event the index knew
750
+ // nothing of it, so the relay answered not-shared for a session
751
+ // whose share it inherits through its parent, and the forwarded
752
+ // status frames the client needs to announce the row never left.
753
+ parentIndex.observe(agent.session)
746
754
  if (source !== 'startup') return
747
755
  onSessionCreated(agent.session.header, {
748
756
  store,
@@ -1578,6 +1586,11 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1578
1586
  // answer — the rewriteIds deep scan rewrites an id that already sits
1579
1587
  // in the browser's view even when this request never carried it.
1580
1588
  getKnownRealIds: () => knownRealIds,
1589
+ // PB-26a: a response rewrite that minted a virtual id (a sidechat
1590
+ // child from `rewriteIds.fields` or an adapter's `virtualize`) is the
1591
+ // client's only early notice of a row that rides no workspace/follow
1592
+ // frame — hand it to the summary sync so the sidebar row appears.
1593
+ onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids) },
1581
1594
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1582
1595
  })
1583
1596
  pluginRouteHandle = handle
package/src/intercept.ts CHANGED
@@ -671,6 +671,12 @@ export interface InterceptHandle {
671
671
  * re-judges NOW — the mergers dedupe no-change calls — instead of
672
672
  * waiting for the next frame or the 1s fallback poll. */
673
673
  currentSessionChanged(): void
674
+ /** PB-26a: trigger a summary-sync check for virtual session ids discovered
675
+ * OUTSIDE workspace/follow (a sidechat childId the plugin bridge just
676
+ * minted in a response rewrite, for instance). Empty and non-virtual
677
+ * entries are dropped; a batch with nothing left is a no-op. Reaches every
678
+ * live merged `$events` stream, and the pending slot when none is open. */
679
+ nudgeSummarySync(ids: readonly string[]): void
674
680
  }
675
681
 
676
682
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -2013,6 +2019,15 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2013
2019
  /** The ids the in-flight round asked (its triggering batches) — marked at
2014
2020
  * completion, joined by nothing (mid-flight batches queue instead). */
2015
2021
  let askingNow: string[] = []
2022
+ /** PB-26a: the newest REAL-TIME running flag this stream delivered per
2023
+ * VIRTUAL session id (the leg's forwarded `api-session/status` frames
2024
+ * only — never the synthesized status-sync frames). Read when a row is
2025
+ * first announced: a status frame that reached the UI BEFORE its row
2026
+ * existed was dropped by the host (handleSessionStatus only updates
2027
+ * sessions already in its summaries), and a flip during the list pull is
2028
+ * newer than the snapshot's `running` — either way the announce must be
2029
+ * followed by the live value. Cleared with a fresh serving period. */
2030
+ const latestLiveStatus = new Map<string, boolean>()
2016
2031
  const scheduleRetry = (): void => {
2017
2032
  if (retryTimer !== undefined || retryAt.size === 0) return
2018
2033
  const earliest = Math.min(...retryAt.values())
@@ -2072,6 +2087,15 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2072
2087
  if (announced.has(rowId)) continue
2073
2088
  announced.add(rowId)
2074
2089
  channel.push(frame)
2090
+ // PB-26a: the row is born in the UI carrying the snapshot's
2091
+ // `running`; when this stream delivered a live status for it the
2092
+ // host could not apply (no row yet) or that postdates the
2093
+ // snapshot, realign right behind the added frame — only when the
2094
+ // two disagree, a matching flag needs no second frame.
2095
+ const liveRunning = latestLiveStatus.get(rowId)
2096
+ if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
2097
+ channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] })
2098
+ }
2075
2099
  }
2076
2100
  // T65-fix ask bookkeeping: a returned id is announced and done; an
2077
2101
  // id the server did NOT return either exhausts its attempts or
@@ -2206,6 +2230,10 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2206
2230
  askedForGood.clear()
2207
2231
  askAttempts.clear()
2208
2232
  retryAt.clear()
2233
+ // PB-26a: the live flags belong to the serving period that delivered
2234
+ // them — the status re-sync below re-reads every row from a fresh
2235
+ // snapshot.
2236
+ latestLiveStatus.clear()
2209
2237
  if (retryTimer !== undefined) {
2210
2238
  clock.clearTimeout(retryTimer)
2211
2239
  retryTimer = undefined
@@ -2370,6 +2398,20 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2370
2398
  if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
2371
2399
  const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
2372
2400
  if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
2401
+ // PB-26a: the rewritten frame is the proved shape (`args` =
2402
+ // [virtualId, running]). Remember the live flag under the
2403
+ // VIRTUAL id, and — when this stream has not announced the row
2404
+ // — pull the list now: a sidechat / subagent child rides no
2405
+ // workspace/follow frame, so this status is the client's first
2406
+ // (and, until the next turn, only) notice that the row exists.
2407
+ // The frame itself still crosses at once, below.
2408
+ const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined
2409
+ const virtualId = args?.[0]
2410
+ const running = args?.[1]
2411
+ if (typeof virtualId === 'string' && typeof running === 'boolean') {
2412
+ latestLiveStatus.set(virtualId, running)
2413
+ if (!announced.has(virtualId)) handleForwardedBatch([virtualId])
2414
+ }
2373
2415
  }
2374
2416
  // The orphan-close bookkeeping rides the two id-carrying frame
2375
2417
  // types: waterfalls enter the record (once per leg — a repeat
@@ -3498,6 +3540,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3498
3540
  currentSessionChanged() {
3499
3541
  for (const listener of [...currentSessionListeners]) listener()
3500
3542
  },
3543
+ nudgeSummarySync(ids: readonly string[]) {
3544
+ const virtualIds = ids.filter((id) => typeof id === 'string' && id !== '' && isVirtual(id))
3545
+ if (virtualIds.length > 0) summarySync.noteForwardedSessionIds(virtualIds)
3546
+ },
3501
3547
  }
3502
3548
  }
3503
3549
 
@@ -25,7 +25,7 @@
25
25
  import { createHash } from 'node:crypto'
26
26
  import { isVirtual, fromVirtual } from './virtual-id.js'
27
27
  import { MAX_UPLOAD_BYTES } from './upload-limits.js'
28
- import type { NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
28
+ import type { BridgeLocator, NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
29
29
  import { BRIDGE_METHODS, DEFAULT_ROUTE_BODY_BYTES } from './plugin-bridge.js'
30
30
 
31
31
  export { fromVirtual }
@@ -327,6 +327,15 @@ export function normalizeAdapter(input: unknown, installedVersion: string, optio
327
327
  methods: route.methods.length === 1 && route.methods[0] === 'GET' ? undefined : [...route.methods],
328
328
  maxBody: route.maxBody === DEFAULT_ROUTE_BODY_BYTES ? undefined : route.maxBody,
329
329
  upgrade: route.upgrade ? true : undefined,
330
+ // PB-26b: an upgrade route whose ids do NOT ride the query keeps its
331
+ // locator on the registry entry — the tunnel's validation and the
332
+ // client's upgrade surrogate read where the ids actually arrive (the
333
+ // sidechat ws watch carries them in the post-handshake JSON frames).
334
+ // A query route's declaration stays EXACTLY the 2.1 shape — no
335
+ // locator key — so every older consumer and digest is untouched
336
+ // (the adapter digest always covered route.locator and never
337
+ // covered the declaration routes' shape).
338
+ ...(route.upgrade && route.locator.in !== 'query' ? { locator: route.locator as BridgeLocator } : {}),
330
339
  })),
331
340
  digest,
332
341
  }
@@ -483,6 +492,16 @@ export function locateAdapterIds(route: NormalizedAdapterRoute, pathname: string
483
492
  for (const field of locator.fields) {
484
493
  const value = body[field]
485
494
  if (typeof value === 'string' && value !== '') hits.push({ kind: 'json-body', key: field, value })
495
+ // PB-26b: a field may carry a LIST of ids — better-sidebar's sidechat
496
+ // ws watch frame sends `{"op":"watch","threadIds":[...]}`. Every
497
+ // non-empty string element is one hit under the same field key, so
498
+ // classify/rewrite treat the batch exactly like repeated query
499
+ // values: every id virtual → remote, one mixed in → refuse.
500
+ else if (Array.isArray(value)) {
501
+ for (const item of value) {
502
+ if (typeof item === 'string' && item !== '') hits.push({ kind: 'json-body', key: field, value: item })
503
+ }
504
+ }
486
505
  }
487
506
  }
488
507
  }
@@ -549,7 +568,15 @@ export function rewriteAdapterRequest(
549
568
  } else {
550
569
  if (bodyCopy === undefined && isRecord(body)) bodyCopy = { ...body }
551
570
  if (bodyCopy !== undefined) {
552
- bodyCopy[hit.key!] = real
571
+ const current = bodyCopy[hit.key!]
572
+ // PB-26b: an array field rewrites IN PLACE, element for element —
573
+ // only the entries equal to this hit's located value swap for their
574
+ // real halves, so a mixed batch of ids keeps its shape and order.
575
+ if (Array.isArray(current)) {
576
+ bodyCopy[hit.key!] = current.map((item) => (item === hit.value ? real : item))
577
+ } else {
578
+ bodyCopy[hit.key!] = real
579
+ }
553
580
  bodyText = JSON.stringify(bodyCopy)
554
581
  }
555
582
  }