dsh-sidecard-ask 2.0.0 → 2.0.2

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 (5) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +17 -12
  3. package/client.js +201 -43
  4. package/index.js +443 -72
  5. package/package.json +4 -1
package/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * 1. Serve the plugin's own JSON + SSE API under `/sidecard-ask/api`.
6
6
  * 2. Own the plugin's configuration: built-in defaults, the bundle patch
7
7
  * layer, and the user layer persisted to
8
- * `<DSH_HOME>/sidecard-ask/config.json`.
8
+ * `<DSH_HOME>/sidecard-ask/config.<DSH_PROFILE>.json` (the plain
9
+ * `config.json` when no profile is set).
9
10
  * 3. Run one independent "side answer" per request: a child Agent started
10
11
  * through `ctx.subagents` whose live deltas are bridged from the
11
12
  * process-local `agent/assistant-stream` event onto the SSE response.
@@ -44,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
44
45
  export const inject = ['webServer']
45
46
 
46
47
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
47
- export const PLUGIN_VERSION = '2.0.0'
48
+ export const PLUGIN_VERSION = '2.0.2'
48
49
 
49
50
  /** Route prefix of the plugin's own API. */
50
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -58,6 +59,22 @@ const SSE_HEARTBEAT_MS = 15_000
58
59
  /** Hard cap on the prompt text handed to the child (defense in depth). */
59
60
  const MAX_PROMPT_CHARS = 60_000
60
61
 
62
+ /**
63
+ * Hard cap on the follow-up question inside the prompt (defense in depth).
64
+ *
65
+ * The question is the child's instruction, so it is capped far above anything
66
+ * a person types and far below the prompt budget: an unbounded question used
67
+ * to be able to squeeze the selection down to a single character.
68
+ */
69
+ const MAX_QUESTION_CHARS = 20_000
70
+
71
+ /**
72
+ * Code points the selection keeps before earlier turns are dropped from the
73
+ * prompt. Without a floor, a long question plus a full history could starve
74
+ * the selection to one character while the prompt still overflowed.
75
+ */
76
+ const SELECTION_FLOOR_CHARS = 2_000
77
+
61
78
  /**
62
79
  * Built-in defaults. `cordis.patch.yml` overrides these; the user layer
63
80
  * persisted by the Client settings page overrides the patch.
@@ -172,7 +189,7 @@ export const DEFAULT_CONFIG = {
172
189
  * SMTP transport, editable in the settings page so a normal user never has to
173
190
  * touch environment variables.
174
191
  *
175
- * These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config.json`,
192
+ * These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config.<profile>.json`,
176
193
  * outside the repository, never in a release artifact). The environment still
177
194
  * works and still wins nothing: an empty host means "read the environment".
178
195
  * The password is write-only over the wire: `/state` never returns it, an
@@ -203,6 +220,14 @@ const QUICK_ACTION_LIMITS = {
203
220
  */
204
221
  export const HISTORY_TURNS = 6
205
222
 
223
+ /**
224
+ * Per-turn character caps inside the built prompt: a single very long earlier
225
+ * answer must not consume the whole 60k character budget and push the current
226
+ * question out (see {@link buildSidePrompt}'s budget allocation).
227
+ */
228
+ const HISTORY_TURN_Q_CHARS = 500
229
+ const HISTORY_TURN_A_CHARS = 4000
230
+
206
231
  /** Allowed values per enum key; anything else falls back to the default. */
207
232
  const ENUMS = {
208
233
  trigger: ['selection', 'shortcut', 'both'],
@@ -522,6 +547,10 @@ export function normalizeConfig(patchConfig, persisted) {
522
547
  * a Plane-2 CJK glyph spans two code units, and slicing the pair's midpoint
523
548
  * would push a broken character into the child's prompt. The client mirrors
524
549
  * this helper exactly (asserted by the contract test).
550
+ *
551
+ * The omission note is charged to the budget, not added on top of it: an
552
+ * "inclusive cap" that the note itself overflows by ~19 characters is not a
553
+ * cap, and the caller sizes this budget from what a fixed prompt leaves over.
525
554
  * @param {string} text - the selected text.
526
555
  * @param {number} maxChars - inclusive cap, in code points.
527
556
  * @returns {{text: string, truncated: boolean, droppedChars: number}}
@@ -529,13 +558,21 @@ export function normalizeConfig(patchConfig, persisted) {
529
558
  export function truncateSelection(text, maxChars) {
530
559
  const source = typeof text === 'string' ? text : ''
531
560
  const points = Array.from(source)
532
- if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
533
- const head = Math.max(1, Math.ceil(maxChars * 0.7))
534
- const tail = Math.max(0, maxChars - head)
561
+ const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
562
+ if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
563
+ // Reserve the note's UPPER bound: the dropped count never exceeds the total,
564
+ // so the widest possible note is what the budget has to survive.
565
+ const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
566
+ const room = cap - noteLength
567
+ if (room < 2) {
568
+ // Too little budget for a note: a plain head cut stays inside the cap.
569
+ return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
570
+ }
571
+ const head = Math.max(1, Math.ceil(room * 0.7))
572
+ const tail = Math.max(0, room - head)
535
573
  const dropped = points.length - head - tail
536
- const marker = `\n…(已省略中间 ${dropped} 个字符)…\n`
537
574
  return {
538
- text: `${points.slice(0, head).join('')}${marker}${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
575
+ text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
539
576
  truncated: true,
540
577
  droppedChars: dropped,
541
578
  }
@@ -556,24 +593,88 @@ export function truncateSelection(text, maxChars) {
556
593
  * @returns {string} the prompt text.
557
594
  */
558
595
  export function buildSidePrompt(input) {
596
+ return composeSidePrompt(input).text
597
+ }
598
+
599
+ /**
600
+ * Build the child's prompt and report what the budget cost the user.
601
+ *
602
+ * The caller used to learn nothing about prompt-side truncation: a selection
603
+ * lost to the budget was silent because only the client-side cut was reported.
604
+ * @param {{selection: string, question: string, zone: string, truncated: boolean,
605
+ * history: Array<{question: string, answer: string}>, historyTurns: number}} input
606
+ * @returns {{text: string, truncated: boolean, droppedChars: number,
607
+ * historyTurns: number, historyDropped: number}} the prompt and its budget cost.
608
+ */
609
+ function composeSidePrompt(input) {
559
610
  const zoneLabel = { chat: 'chat area', task: 'task area', other: 'elsewhere' }[input.zone] ?? 'elsewhere'
560
- const parts = [
561
- `Source: ${zoneLabel}${input.truncated ? ' (selection too long; truncated)' : ''}`,
562
- 'Selection:',
563
- '```text',
564
- input.selection,
565
- '```',
566
- ]
567
- const turns = Array.isArray(input.history) ? input.history.slice(-Math.max(0, input.historyTurns)) : []
568
- if (turns.length > 0) {
569
- parts.push('Earlier turns on this card:')
570
- for (const turn of turns) {
571
- parts.push(`Question: ${turn.question}`)
572
- if (turn.answer) parts.push(`Answer: ${turn.answer}`)
573
- }
611
+ const capTurn = (value, limit) => {
612
+ const text = typeof value === 'string' ? value : ''
613
+ return text.length <= limit ? text : `${text.slice(0, limit)}…`
614
+ }
615
+ // Last N turns; each turn's fields are individually bounded so one long
616
+ // earlier answer cannot consume the whole budget. Entries that are not
617
+ // objects are dropped instead of thrown on: `history: [null]` used to raise a
618
+ // TypeError out of `ask()` before its try block, which reached the Client as
619
+ // a raw English message and with no `start` frame at all.
620
+ const limit = Number.isFinite(input.historyTurns)
621
+ ? Math.max(0, Math.floor(input.historyTurns))
622
+ : HISTORY_TURNS
623
+ const validTurns = (Array.isArray(input.history) ? input.history : [])
624
+ .filter(turn => turn !== null && typeof turn === 'object' && !Array.isArray(turn))
625
+ // `slice(-0)` is `slice(0)` — the whole list. A caller asking for zero turns
626
+ // must get zero turns, not every turn.
627
+ const turns = limit === 0 ? [] : validTurns.slice(-limit)
628
+ const questionBlock = `Question: ${capTurn(input.question, MAX_QUESTION_CHARS)}`
629
+ const sourceLine = `Source: ${zoneLabel}${input.truncated ? ' (selection too long; truncated)' : ''}`
630
+
631
+ /**
632
+ * Assemble the prompt for one selection and one set of turns. Used both for
633
+ * the real prompt and for the empty-selection skeleton, so the fixed length
634
+ * is MEASURED instead of re-derived: the old hand-counted sum missed the
635
+ * separator between the selection and its closing fence.
636
+ */
637
+ const assemble = (selection, historyTurns) => {
638
+ const parts = [sourceLine, 'Selection:', '```text', selection, '```']
639
+ if (historyTurns.length > 0) {
640
+ const historyLines = ['Earlier turns on this card:']
641
+ for (const turn of historyTurns) {
642
+ historyLines.push(`Question: ${capTurn(turn.question, HISTORY_TURN_Q_CHARS)}`)
643
+ const answer = capTurn(turn.answer, HISTORY_TURN_A_CHARS)
644
+ if (answer !== '') historyLines.push(`Answer: ${answer}`)
645
+ }
646
+ parts.push(historyLines.join('\n'))
647
+ }
648
+ parts.push(questionBlock)
649
+ return parts.join('\n')
650
+ }
651
+
652
+ const rawSelection = typeof input.selection === 'string' ? input.selection : ''
653
+ const selectionLength = Array.from(rawSelection).length
654
+ // The question is the instruction and must survive the cap; the history
655
+ // block is fixed-length; only the SELECTION absorbs what remains. Slicing the
656
+ // whole joined string used to drop "Question:" when the selection filled 60k
657
+ // characters, leaving the child with no instruction.
658
+ //
659
+ // The history is the only slack: if the fixed blocks would starve the
660
+ // selection below its floor, the OLDEST turns go first (the newest context is
661
+ // the most relevant), and if even a bare skeleton cannot hold the floor the
662
+ // question cap above is what keeps the budget finite.
663
+ const floor = Math.min(SELECTION_FLOOR_CHARS, selectionLength)
664
+ let kept = turns
665
+ let skeleton = assemble('', kept).length
666
+ while (kept.length > 0 && MAX_PROMPT_CHARS - skeleton < floor) {
667
+ kept = kept.slice(1)
668
+ skeleton = assemble('', kept).length
669
+ }
670
+ const cut = truncateSelection(rawSelection, Math.max(0, MAX_PROMPT_CHARS - skeleton))
671
+ return {
672
+ text: assemble(cut.text, kept),
673
+ truncated: cut.truncated || kept.length !== turns.length,
674
+ droppedChars: cut.droppedChars,
675
+ historyTurns: kept.length,
676
+ historyDropped: turns.length - kept.length,
574
677
  }
575
- parts.push('Question:', input.question)
576
- return parts.join('\n').slice(0, MAX_PROMPT_CHARS)
577
678
  }
578
679
 
579
680
  // ────────────────────────────────────────────────────────────────────────────
@@ -725,8 +826,20 @@ export function parseSmtpUrl(raw) {
725
826
  if (host === '') return { ok: false, reason: 'SMTP 地址缺少主机名' }
726
827
  const port = url.port === '' ? (secure ? 465 : 587) : Number(url.port)
727
828
  if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'SMTP 端口不在 1–65535 之间' }
728
- const user = decodeURIComponent(url.username ?? '')
729
- const pass = decodeURIComponent(url.password ?? '')
829
+ // A password pasted into an environment variable is not URL-escaped by its
830
+ // author: `pass%word` reaches here as `%wo`, and `decodeURIComponent` rejects
831
+ // that with a URIError. The throw used to escape `parseSmtpUrl` →
832
+ // `readSmtpFromEnv` → `describeFeedback` → `/state`, where it surfaced as
833
+ // "host unreachable" and took the whole settings page with it — one stray `%`
834
+ // in a credential must be a config error, never a broken page.
835
+ let user
836
+ let pass
837
+ try {
838
+ user = decodeURIComponent(url.username ?? '')
839
+ pass = decodeURIComponent(url.password ?? '')
840
+ } catch {
841
+ return { ok: false, reason: 'SMTP 账号或密码含未转义的 %(请把 % 写成 %25)' }
842
+ }
730
843
  if (user === '' || pass === '') return { ok: false, reason: 'SMTP 地址需要 user:pass' }
731
844
  const from = (url.searchParams.get('from') ?? '').trim() || user
732
845
  if (!looksLikeEmail(from)) return { ok: false, reason: '发件邮箱格式不合法(由 ?from= 指定)' }
@@ -818,18 +931,28 @@ export function resolveSmtp(config = {}, env = process.env) {
818
931
 
819
932
  /**
820
933
  * The SMTP settings as the page may see them: everything except the password,
821
- * which is reduced to a boolean.
934
+ * which is reduced to boolean facts.
822
935
  */
823
936
  export function describeSmtpSettings(config = {}, env = process.env) {
824
937
  const resolved = resolveSmtp(config, env)
938
+ const stored = typeof config.feedbackSmtpPass === 'string' && config.feedbackSmtpPass !== ''
939
+ const source = resolved.ok === true ? resolved.source : ''
825
940
  return {
826
941
  host: typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : '',
827
942
  port: clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? 465,
828
943
  secure: config.feedbackSmtpSecure !== false,
829
944
  user: typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : '',
830
945
  from: typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '',
831
- hasPassword: typeof config.feedbackSmtpPass === 'string' && config.feedbackSmtpPass !== '',
832
- source: resolved.ok === true ? resolved.source : '',
946
+ // True when a password is actually available to send with, from EITHER
947
+ // layer. This used to read the config field only, so an operator who
948
+ // exported the credentials got `configured: true` next to
949
+ // `hasPassword: false` — a descriptor that contradicted itself and made a
950
+ // working setup look broken in diagnostics.
951
+ hasPassword: source === 'env' ? true : stored,
952
+ // Which layer the active credentials come from, and whether the settings
953
+ // form itself holds a password (what "leave blank to keep it" refers to).
954
+ source,
955
+ storedPassword: stored,
833
956
  }
834
957
  }
835
958
 
@@ -1133,6 +1256,28 @@ export function configDir() {
1133
1256
  return join(base, 'sidecard-ask')
1134
1257
  }
1135
1258
 
1259
+ /**
1260
+ * The persisted-config file for THIS harness profile.
1261
+ *
1262
+ * `$DSH_HOME` is one directory shared by every profile of an installation —
1263
+ * the web shell and the desktop shell are two profiles over the same home — so
1264
+ * a single `config.json` let one end's save silently rewrite the other end's
1265
+ * settings (a web-only `sideTools: 'inherit'` then applied to desktop runs).
1266
+ * `$DSH_PROFILE` is the harness' own profile identity, so it becomes part of
1267
+ * the file name.
1268
+ *
1269
+ * The profile segment is restricted to a conservative token: this value
1270
+ * reaches a filesystem path, so anything else falls back to the shared file
1271
+ * rather than escaping the directory. A shell that sets no profile (older
1272
+ * harness, bare `node` runs) keeps the historical path exactly.
1273
+ * @returns {string} absolute file path.
1274
+ */
1275
+ export function configFilePath() {
1276
+ const profile = typeof process.env.DSH_PROFILE === 'string' ? process.env.DSH_PROFILE.trim() : ''
1277
+ const slug = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile) ? profile : ''
1278
+ return join(configDir(), slug === '' ? 'config.json' : `config.${slug}.json`)
1279
+ }
1280
+
1136
1281
  /**
1137
1282
  * Build the read-only tool restriction for a child run.
1138
1283
  *
@@ -1246,7 +1391,10 @@ function writeOk(res, value) {
1246
1391
  * relies on `retry-after` surviving).
1247
1392
  */
1248
1393
  function writeError(res, status, code, message, extraHeaders = {}) {
1249
- writeJson(res, status, { ok: false, error: { code, message } }, extraHeaders)
1394
+ if (res === null || res === undefined || res.destroyed === true || res.headersSent === true) return
1395
+ try {
1396
+ writeJson(res, status, { ok: false, error: { code, message } }, extraHeaders)
1397
+ } catch { /* the socket is already gone — nothing to write */ }
1250
1398
  }
1251
1399
 
1252
1400
  /**
@@ -1257,27 +1405,54 @@ function readJsonBody(req) {
1257
1405
  return new Promise((resolve, reject) => {
1258
1406
  const chunks = []
1259
1407
  let size = 0
1260
- req.on('data', (chunk) => {
1408
+ let settled = false
1409
+ const cleanup = () => {
1410
+ // Guard: synthetic request stubs (and very old streams) may only implement
1411
+ // on(); removeListener is a no-op there rather than a crash.
1412
+ if (typeof req.removeListener === 'function') {
1413
+ req.removeListener('data', onData)
1414
+ req.removeListener('end', onEnd)
1415
+ }
1416
+ // The 'error' listener stays attached for the socket lifetime: removing
1417
+ // it would turn a later ECONNRESET after destroy into an unhandled crash;
1418
+ // onError no-ops once settled is true.
1419
+ }
1420
+ const fail = (error) => {
1421
+ if (settled) return
1422
+ settled = true
1423
+ cleanup()
1424
+ reject(error)
1425
+ }
1426
+ const onData = (chunk) => {
1261
1427
  size += chunk.length
1262
1428
  if (size > MAX_BODY_BYTES) {
1263
- reject(Object.assign(new Error('请求体过大'), { code: 'too-large' }))
1429
+ fail(Object.assign(new Error('请求体过大'), { code: 'too-large' }))
1264
1430
  req.destroy()
1265
1431
  return
1266
1432
  }
1267
1433
  chunks.push(chunk)
1268
- })
1269
- req.on('end', () => {
1434
+ }
1435
+ const onEnd = () => {
1436
+ if (settled) return
1270
1437
  if (size === 0) {
1438
+ settled = true
1439
+ cleanup()
1271
1440
  resolve(null)
1272
1441
  return
1273
1442
  }
1274
1443
  try {
1275
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
1444
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
1445
+ settled = true
1446
+ cleanup()
1447
+ resolve(value)
1276
1448
  } catch {
1277
- reject(Object.assign(new Error('请求体不是合法 JSON'), { code: 'bad-request' }))
1449
+ fail(Object.assign(new Error('请求体不是合法 JSON'), { code: 'bad-request' }))
1278
1450
  }
1279
- })
1280
- req.on('error', (error) => { reject(error) })
1451
+ }
1452
+ const onError = (error) => { fail(error) }
1453
+ req.on('data', onData)
1454
+ req.on('end', onEnd)
1455
+ req.on('error', onError)
1281
1456
  })
1282
1457
  }
1283
1458
 
@@ -1290,12 +1465,24 @@ function openSse(res) {
1290
1465
  'x-accel-buffering': 'no',
1291
1466
  })
1292
1467
  let closed = false
1293
- const heartbeat = setInterval(() => {
1468
+ let heartbeat = null
1469
+ /**
1470
+ * Mark the stream dead and stop the heartbeat exactly once.
1471
+ *
1472
+ * `end()` alone was not enough: a failed `write` set `closed = true`, and
1473
+ * `end()` then early-returned on that flag WITHOUT clearing the interval — an
1474
+ * orphaned timer ticking every 15s for the rest of the process lifetime.
1475
+ */
1476
+ const shutdown = () => {
1477
+ closed = true
1478
+ if (heartbeat !== null) clearInterval(heartbeat)
1479
+ }
1480
+ heartbeat = setInterval(() => {
1294
1481
  if (closed) return
1295
1482
  try {
1296
1483
  res.write(': keep-alive\n\n')
1297
1484
  } catch {
1298
- closed = true
1485
+ shutdown()
1299
1486
  }
1300
1487
  }, SSE_HEARTBEAT_MS)
1301
1488
  heartbeat.unref?.()
@@ -1306,14 +1493,13 @@ function openSse(res) {
1306
1493
  try {
1307
1494
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
1308
1495
  } catch {
1309
- closed = true
1496
+ shutdown()
1310
1497
  }
1311
1498
  },
1312
1499
  /** End the stream exactly once. */
1313
1500
  end() {
1314
1501
  if (closed) return
1315
- closed = true
1316
- clearInterval(heartbeat)
1502
+ shutdown()
1317
1503
  try {
1318
1504
  res.end()
1319
1505
  } catch {
@@ -1564,7 +1750,8 @@ function createSideEngine(ctx, configOf) {
1564
1750
  }
1565
1751
 
1566
1752
  /** Report what the side card can do right now. */
1567
- function capabilities() { const providers = providerNames()
1753
+ function capabilities() {
1754
+ const providers = providerNames()
1568
1755
  const service = subagentsService()
1569
1756
  const parent = resolveParent(undefined)
1570
1757
  return {
@@ -1663,7 +1850,9 @@ function createSideEngine(ctx, configOf) {
1663
1850
  }
1664
1851
 
1665
1852
  const cut = truncateSelection(rawSelection, config.maxChars)
1666
- const prompt = buildSidePrompt({
1853
+ // `composeSidePrompt` reports what the total prompt budget cost so the
1854
+ // start frame can surface it; `buildSidePrompt` is the string-only wrapper.
1855
+ const composed = composeSidePrompt({
1667
1856
  selection: cut.text,
1668
1857
  question,
1669
1858
  zone: typeof request.zone === 'string' ? request.zone : 'other',
@@ -1671,6 +1860,7 @@ function createSideEngine(ctx, configOf) {
1671
1860
  history: Array.isArray(request.history) ? request.history : [],
1672
1861
  historyTurns: HISTORY_TURNS,
1673
1862
  })
1863
+ const prompt = composed.text
1674
1864
 
1675
1865
  const controller = new AbortController()
1676
1866
  const abortOnClientGone = () => { controller.abort() }
@@ -1734,6 +1924,12 @@ function createSideEngine(ctx, configOf) {
1734
1924
  maxChars: config.maxChars,
1735
1925
  truncated: cut.truncated,
1736
1926
  droppedChars: cut.droppedChars,
1927
+ // Prompt-side budget cost, which the Client could not see before: the
1928
+ // selection can lose MORE characters to the total prompt budget than
1929
+ // the client-side cut dropped, and older turns can be shed entirely.
1930
+ promptTruncated: composed.truncated,
1931
+ promptDroppedChars: composed.droppedChars,
1932
+ promptHistoryDropped: composed.historyDropped,
1737
1933
  // Reported so the card can say the run inherits the session's tools
1738
1934
  // instead of silently pretending the read-only guard is in force.
1739
1935
  toolFilter: config.sideTools !== 'readonly'
@@ -1812,6 +2008,15 @@ function createSideEngine(ctx, configOf) {
1812
2008
  .trim()
1813
2009
  text = joined
1814
2010
  }
2011
+ // Non-streaming builds lose reasoning when only the text fallback ran:
2012
+ // also recover reasoning/thinking blocks from the durable result.output.
2013
+ if (reasoning.trim() === '') {
2014
+ reasoning = (Array.isArray(result?.output) ? result.output : [])
2015
+ .filter(block => (block?.type === 'reasoning' || block?.type === 'thinking') && typeof block.text === 'string')
2016
+ .map(block => block.text)
2017
+ .join('\n')
2018
+ .trim()
2019
+ }
1815
2020
  const stopReason = typeof result?.stopReason === 'string' ? result.stopReason : 'completed'
1816
2021
  if (text.trim() === '' && stopReason !== 'completed') {
1817
2022
  // `refusal` is the subagent seam's name for a turn the loop ended as
@@ -1841,6 +2046,9 @@ function createSideEngine(ctx, configOf) {
1841
2046
  text,
1842
2047
  reasoning,
1843
2048
  streaming: framesSeen || chunksSeen,
2049
+ // True when the child returned no prose at all (reasoning-only or a
2050
+ // silent completion); the Client already shows its empty notice.
2051
+ empty: text.trim() === '',
1844
2052
  // Which source carried the deltas — the Client uses it only for the
1845
2053
  // "this build publishes no stream frames" note.
1846
2054
  streamSource: framesSeen ? 'frames' : (chunksSeen ? 'chunks' : 'result'),
@@ -1904,14 +2112,13 @@ function createSideEngine(ctx, configOf) {
1904
2112
  // ────────────────────────────────────────────────────────────────────────────
1905
2113
 
1906
2114
  /**
1907
- * Read the persisted user layer. A missing or unreadable file is not an
1908
- * error — it only means "no user overrides yet".
2115
+ * Read ONE config file. A missing or unreadable file is not an error — it only
2116
+ * means "no user overrides here".
2117
+ * @param {string} path - absolute file path.
1909
2118
  * @param {object} [logger] - optional ctx.logger.
1910
2119
  * @returns {{data: Record<string, unknown>, path: string, present: boolean, error?: string}}
1911
2120
  */
1912
- function readPersistedConfig(logger) {
1913
- const dir = configDir()
1914
- const path = join(dir, 'config.json')
2121
+ function readConfigFile(path, logger) {
1915
2122
  try {
1916
2123
  const raw = readFileSync(path, 'utf8')
1917
2124
  const parsed = JSON.parse(raw)
@@ -1925,19 +2132,47 @@ function readPersistedConfig(logger) {
1925
2132
  }
1926
2133
  }
1927
2134
 
2135
+ /**
2136
+ * Read the persisted user layer for this profile.
2137
+ *
2138
+ * A profile-scoped file that has never been written inherits the old shared
2139
+ * `config.json` once, so an existing installation keeps its settings when the
2140
+ * file name becomes profile-aware. The shared file is never moved or deleted:
2141
+ * an older plugin build still reads it.
2142
+ * @param {object} [logger] - optional ctx.logger.
2143
+ * @returns {{data: Record<string, unknown>, path: string, present: boolean,
2144
+ * inheritedFrom?: string, error?: string}}
2145
+ */
2146
+ function readPersistedConfig(logger) {
2147
+ const path = configFilePath()
2148
+ const own = readConfigFile(path, logger)
2149
+ if (own.present === true) return own
2150
+ const legacy = join(configDir(), 'config.json')
2151
+ if (path === legacy) return own
2152
+ const inherited = readConfigFile(legacy, logger)
2153
+ if (inherited.present !== true) return own
2154
+ return { ...inherited, path, inheritedFrom: legacy }
2155
+ }
2156
+
1928
2157
  /**
1929
2158
  * Persist the user layer atomically (write a sibling temp file, then rename).
1930
- * @returns {{ok: boolean, error?: string}} the outcome.
2159
+ * Always writes THIS profile's file, never the shared one.
2160
+ * @param {Record<string, unknown>} data - the user layer.
2161
+ * @param {object} [logger] - optional ctx.logger.
2162
+ * @returns {{ok: boolean, path: string, error?: string}} the outcome.
1931
2163
  */
1932
2164
  function writePersistedConfig(data, logger) {
1933
2165
  const dir = configDir()
1934
- const path = join(dir, 'config.json')
2166
+ const path = configFilePath()
1935
2167
  const temp = `${path}.tmp`
1936
2168
  try {
1937
- mkdirSync(dir, { recursive: true })
1938
- writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
2169
+ // POSIX hardening: the file holds the SMTP password, so it must not be
2170
+ // world-readable; the mode bits are no-ops on Windows but keep the
2171
+ // behavior correct on Linux/macOS.
2172
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
2173
+ writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
1939
2174
  renameSync(temp, path)
1940
- return { ok: true }
2175
+ return { ok: true, path }
1941
2176
  } catch (error) {
1942
2177
  try {
1943
2178
  rmSync(temp, { force: true })
@@ -1945,7 +2180,7 @@ function writePersistedConfig(data, logger) {
1945
2180
  /* best effort */
1946
2181
  }
1947
2182
  logger?.warn?.('[sidecard-ask] 保存配置失败:', error?.message ?? error)
1948
- return { ok: false, error: String(error?.message ?? error) }
2183
+ return { ok: false, path, error: String(error?.message ?? error) }
1949
2184
  }
1950
2185
  }
1951
2186
 
@@ -2063,6 +2298,59 @@ export function apply(ctx, patchConfig) {
2063
2298
  return out
2064
2299
  }
2065
2300
 
2301
+ /**
2302
+ * Whether a sanitized report carries any observation at all.
2303
+ *
2304
+ * `sanitizeClientReport` always produces the `at/slots/native/better` shell,
2305
+ * so an empty POST used to look like a valid report and erased the previous
2306
+ * one. Only fields the Client actually observed count.
2307
+ * @param {Record<string, any>} report - sanitized report.
2308
+ * @returns {boolean} true when at least one observation is present.
2309
+ */
2310
+ function reportHasContent(report) {
2311
+ if (report === null || typeof report !== 'object') return false
2312
+ for (const key of ['version', 'surface', 'zoneAnchors', 'sessionKnown', 'family']) {
2313
+ if (report[key] !== undefined) return true
2314
+ }
2315
+ if (report.slots !== undefined && Object.keys(report.slots).length > 0) return true
2316
+ for (const key of ['native', 'better']) {
2317
+ const block = report[key]
2318
+ if (block !== undefined && Object.keys(block).length > 0) return true
2319
+ }
2320
+ return false
2321
+ }
2322
+
2323
+ /**
2324
+ * Fold one report into the accumulated view instead of replacing it.
2325
+ *
2326
+ * The channel is a single last-write-wins slot fed by every page of every
2327
+ * tab, so a replacement loses whatever the previous reporter knew (a page
2328
+ * that only re-registered one slot would blank the other three). Undefined
2329
+ * fields mean "not observed this time" and keep the stored value; only
2330
+ * fields the sender actually reported overwrite.
2331
+ * @param {Record<string, any>|null} previous - accumulated report.
2332
+ * @param {Record<string, any>} next - freshly sanitized report.
2333
+ * @returns {Record<string, any>} merged report.
2334
+ */
2335
+ function mergeClientReport(previous, next) {
2336
+ if (previous === null || typeof previous !== 'object') return next
2337
+ const merged = { ...previous, at: next.at }
2338
+ for (const key of ['version', 'surface', 'zoneAnchors', 'sessionKnown', 'family']) {
2339
+ if (next[key] !== undefined) merged[key] = next[key]
2340
+ }
2341
+ for (const key of ['slots', 'native', 'better']) {
2342
+ const incoming = next[key]
2343
+ if (incoming === undefined) continue
2344
+ const stored = previous[key]
2345
+ if (stored === undefined || typeof stored !== 'object') {
2346
+ merged[key] = incoming
2347
+ continue
2348
+ }
2349
+ merged[key] = { ...stored, ...incoming }
2350
+ }
2351
+ return merged
2352
+ }
2353
+
2066
2354
  /** Whether the request may reach the plugin routes. */
2067
2355
  const trustedHostsOf = () => {
2068
2356
  const runtime = ctx.get('webRuntime')
@@ -2089,6 +2377,10 @@ export function apply(ctx, patchConfig) {
2089
2377
  persistedAtBoot: persisted.present && Object.keys(sanitizeLayer(persisted.data)).length > 0,
2090
2378
  persistedPath: persisted.path,
2091
2379
  persistedError: persisted.error,
2380
+ // Set only on the first boot after a profile-scoped file was
2381
+ // introduced: the settings were inherited from the old shared file and
2382
+ // will be re-saved into the scoped one on the next save.
2383
+ persistedInheritedFrom: persisted.inheritedFrom,
2092
2384
  patchKeys: Object.keys(sanitizeLayer(patchConfig)),
2093
2385
  },
2094
2386
  capabilities: {
@@ -2110,6 +2402,11 @@ export function apply(ctx, patchConfig) {
2110
2402
  * A browser-side plugin cannot be inspected from the Host, so this is the
2111
2403
  * only channel that turns "the card silently fell back" or "the native tab
2112
2404
  * never registered" into something readable from outside the page.
2405
+ *
2406
+ * Reports are MERGED, not replaced, and a report with no observations is
2407
+ * refused: the channel is one last-write-wins slot shared by every page, so
2408
+ * a replacement (or an empty POST) used to erase what the real page knew and
2409
+ * made the one outside view of the Client lie.
2113
2410
  */
2114
2411
  const handleDiagnose = async (req, res) => {
2115
2412
  let body
@@ -2124,7 +2421,11 @@ export function apply(ctx, patchConfig) {
2124
2421
  writeError(res, 400, 'bad-request', '诊断上报必须是对象')
2125
2422
  return
2126
2423
  }
2127
- clientReport = report
2424
+ if (!reportHasContent(report)) {
2425
+ writeError(res, 400, 'bad-request', '诊断上报没有任何可识别字段')
2426
+ return
2427
+ }
2428
+ clientReport = mergeClientReport(clientReport, report)
2128
2429
  writeOk(res, { accepted: true, at: report.at })
2129
2430
  }
2130
2431
 
@@ -2248,11 +2549,22 @@ export function apply(ctx, patchConfig) {
2248
2549
  return
2249
2550
  }
2250
2551
  const problems = []
2251
- // The SMTP password is write-only: `/state` never hands it back, so the
2252
- // settings form always submits an empty string for an untouched password.
2253
- // Treat that empty value as "leave it alone" instead of erasing it.
2552
+ // The SMTP password is write-only: `/state` never hands it back, so a
2553
+ // client that submits an empty string for an untouched password means
2554
+ // "leave it alone" instead of erasing it. That rule used to leave NO way to
2555
+ // remove a stored credential at all (empty = keep, anything else =
2556
+ // overwrite), so `feedbackSmtpPassClear: true` is the explicit erase: it
2557
+ // drops the stored password (typically to fall back to the environment) and
2558
+ // is never persisted as a config key itself.
2254
2559
  const patch = { ...body }
2255
- if (patch.feedbackSmtpPass === '') delete patch.feedbackSmtpPass
2560
+ if (patch.feedbackSmtpPassClear === true) {
2561
+ delete patch.feedbackSmtpPassClear
2562
+ delete patch.feedbackSmtpPass
2563
+ delete userLayer.feedbackSmtpPass
2564
+ } else {
2565
+ delete patch.feedbackSmtpPassClear
2566
+ if (patch.feedbackSmtpPass === '') delete patch.feedbackSmtpPass
2567
+ }
2256
2568
  const accepted = sanitizeLayer(patch, problems)
2257
2569
  if (problems.length > 0) {
2258
2570
  writeError(res, 400, 'invalid-config', problems.join(';'))
@@ -2282,33 +2594,92 @@ export function apply(ctx, patchConfig) {
2282
2594
  */
2283
2595
  const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
2284
2596
 
2597
+ /**
2598
+ * The Host's own browser-session gate, borrowed for the plugin's routes.
2599
+ *
2600
+ * `webServer` routes are NOT covered by the Host's index-only
2601
+ * `authorizeIndex`, so without this check any local process that can reach
2602
+ * the loopback port may read `/state`, rewrite settings, and spawn a child
2603
+ * agent with the plugin's own privileges. `connection.requestRejection`
2604
+ * verifies the authority-bound `dsh-auth-*` HMAC cookie the Host mints for
2605
+ * the browser (a plain `dsh-auth-` cookie is NOT enough: the signature is
2606
+ * checked against a secret only the Host holds).
2607
+ *
2608
+ * Returns undefined when the composition has no `connection` service: such a
2609
+ * shell serves no browser UI, so requests can only come from the machine
2610
+ * itself and the loopback/Origin fence is the whole model.
2611
+ */
2612
+ const connectionService = () => {
2613
+ try {
2614
+ const connection = ctx.get('connection')
2615
+ return connection !== undefined && typeof connection.requestRejection === 'function' ? connection : undefined
2616
+ } catch {
2617
+ return undefined
2618
+ }
2619
+ }
2620
+ let authGateNoted = false
2621
+ const authRejection = req => {
2622
+ const connection = connectionService()
2623
+ if (connection === undefined) {
2624
+ if (!authGateNoted) {
2625
+ authGateNoted = true
2626
+ ctx.logger?.info?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏')
2627
+ }
2628
+ return undefined
2629
+ }
2630
+ try {
2631
+ const rejection = connection.requestRejection(req)
2632
+ return rejection === 401 || rejection === 403 ? rejection : undefined
2633
+ } catch (error) {
2634
+ // Fail closed: an unreadable auth service must not widen the surface.
2635
+ ctx.logger?.warn?.('[sidecard-ask] 鉴权校验异常,按未认证处理:', error?.message ?? error)
2636
+ return 401
2637
+ }
2638
+ }
2639
+
2285
2640
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2286
2641
  const routeHandler = async (req, res) => {
2287
2642
  if (!isTrustedRequest(req, trustedHostsOf())) {
2288
2643
  writeError(res, 403, 'forbidden', 'forbidden')
2289
2644
  return
2290
2645
  }
2646
+ const unauthorized = authRejection(req)
2647
+ if (unauthorized !== undefined) {
2648
+ writeError(
2649
+ res,
2650
+ unauthorized,
2651
+ unauthorized === 401 ? 'unauthorized' : 'forbidden',
2652
+ unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
2653
+ )
2654
+ return
2655
+ }
2291
2656
  const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
2292
2657
  const method = pathname.startsWith(`${ROUTE_PREFIX}/`)
2293
2658
  ? pathname.slice(ROUTE_PREFIX.length + 1)
2294
2659
  : ''
2295
- if (method === '' || method.includes('/')) {
2660
+ // The method name decides "unknown" before the HTTP verb does, so a typo
2661
+ // answers the same 404 for every verb (a GET used to fall through to 405).
2662
+ if (method === '' || method.includes('/') || !API_METHODS.includes(method)) {
2296
2663
  writeError(res, 404, 'not-found', `未知接口 "${method}"`)
2297
2664
  return
2298
2665
  }
2299
- if (method === 'state') {
2300
- if (req.method !== 'GET' && req.method !== 'POST') {
2666
+ // Everything that can throw runs inside ONE try: `/state` assembles the
2667
+ // whole diagnostic surface (capabilities, SMTP resolution, provenance), and
2668
+ // it used to sit outside this block, so a single bad config value answered
2669
+ // the browser with an unhandled rejection instead of a readable 500.
2670
+ try {
2671
+ if (method === 'state') {
2672
+ if (req.method !== 'GET' && req.method !== 'POST') {
2673
+ writeError(res, 405, 'method-error', 'method not allowed')
2674
+ return
2675
+ }
2676
+ handleState(res)
2677
+ return
2678
+ }
2679
+ if (req.method !== 'POST') {
2301
2680
  writeError(res, 405, 'method-error', 'method not allowed')
2302
2681
  return
2303
2682
  }
2304
- handleState(res)
2305
- return
2306
- }
2307
- if (req.method !== 'POST') {
2308
- writeError(res, 405, 'method-error', 'method not allowed')
2309
- return
2310
- }
2311
- try {
2312
2683
  if (method === 'ask') await handleAsk(req, res)
2313
2684
  else if (method === 'cancel') await handleCancel(req, res)
2314
2685
  else if (method === 'config') await handleConfig(req, res)