@jjh32456/dsh-longlive 1.6.7 → 1.6.8

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 (41) hide show
  1. package/CHANGELOG.md +40 -8
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/config.js +29 -2
  21. package/src/features.js +26 -0
  22. package/src/heartbeat.js +156 -23
  23. package/src/identity/handover.js +52 -1
  24. package/src/live.js +22 -2
  25. package/src/recall/privacy.mjs +105 -16
  26. package/src/router/service/board.mjs +95 -15
  27. package/src/router/service/hint.mjs +14 -0
  28. package/src/router/service/service.mjs +27 -11
  29. package/test/activity-realtime.test.mjs +959 -0
  30. package/test/activity-stream.test.mjs +512 -0
  31. package/test/autobind.test.mjs +25 -11
  32. package/test/config-schema.test.mjs +6 -0
  33. package/test/feature-flags.test.mjs +17 -0
  34. package/test/fixtures/activity-append-child.mjs +29 -0
  35. package/test/live.test.mjs +6 -3
  36. package/test/packaging.test.mjs +7 -5
  37. package/test/router-background/router.test.mjs +44 -12
  38. package/test/run-rc.mjs +16 -0
  39. package/test/safety-defaults.test.mjs +5 -3
  40. package/test/state-truth.test.mjs +937 -0
  41. package/test/version-consistency.test.mjs +286 -0
@@ -8,11 +8,20 @@
8
8
  * 为什么用 createRequire 而不是静态 import:
9
9
  * - auto-recall 的核心要保持「零 import、可单测」;
10
10
  * - 隐私门是 ESM,但 Node ≥22 支持同步 require(ESM)(本机 v24.21.0 已验证);
11
- * - 这样门缺失时模块仍能加载,退到核心自带的保守 PII 兜底,绝不因「门没装」而放行。
11
+ * - 这样门缺失时模块仍能加载,退到核心自带的保守兜底,绝不因「门没装」而放行。
12
12
  *
13
13
  * 命中口径 = 隐私门的 verdict==='local_only' **或** 核心兜底正则命中(取并集,宁可多拦)。
14
- * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js
15
- * 以及 test/auto-recall.test.mjs(证明真门被调用)。
14
+ * 兜底分两类:
15
+ * - PII:手机号 / 邮箱 / 身份证(1.6.7 起就有);
16
+ * - 密钥/令牌(1.6.8 红队 S3 补):sk-*、Bearer …、*_API_KEY=*、token=/secret=/password=、
17
+ * 私钥头、AWS/GitHub/Slack 凭证前缀。活动板是跨会话共享的,
18
+ * 手机号只泄漏一个人的联系方式,密钥泄漏的是账号本身。
19
+ *
20
+ * 发布形态下(没有 privacy-gate 仓库)只靠兜底;DSH_PRIVACY_GATE 显式配置但不可达时
21
+ * 在加载期 `process.emitWarning` 一次,并强制走强化后的本地规则 —— 不允许静默放行。
22
+ *
23
+ * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js、
24
+ * src/activity/{store,index}.js 以及 test/auto-recall.test.mjs(证明真门被调用)。
16
25
  */
17
26
 
18
27
  import { createRequire } from 'node:module';
@@ -22,15 +31,20 @@ import { fileURLToPath } from 'node:url';
22
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
32
  const require = createRequire(import.meta.url);
24
33
 
34
+ const ENV_GATE = typeof process.env.DSH_PRIVACY_GATE === 'string' && process.env.DSH_PRIVACY_GATE.trim() !== ''
35
+ ? process.env.DSH_PRIVACY_GATE.trim()
36
+ : null;
37
+
25
38
  /** 环境变量优先;其次是作者树旁边的 privacy-gate;最后是绝对路径兜底。 */
26
39
  export const GATE_CANDIDATES = [
27
- process.env.DSH_PRIVACY_GATE,
40
+ ENV_GATE,
28
41
  path.resolve(HERE, '../../../privacy-gate/src/memory/privacy-gate.js'),
29
42
  ].filter((p) => typeof p === 'string' && p.trim() !== '');
30
43
 
31
44
  let realScan = null;
32
45
  let loadedFrom = null;
33
46
  let loadError = null;
47
+ const loadErrors = [];
34
48
  for (const candidate of GATE_CANDIDATES) {
35
49
  try {
36
50
  const mod = require(candidate);
@@ -39,44 +53,116 @@ for (const candidate of GATE_CANDIDATES) {
39
53
  loadedFrom = candidate;
40
54
  break;
41
55
  }
42
- loadError = `scanPrivacy not exported by ${candidate}`;
56
+ loadErrors.push(`scanPrivacy not exported by ${candidate}`);
43
57
  } catch (error) {
44
- loadError = `${candidate}: ${error?.code ?? error?.message ?? error}`;
58
+ loadErrors.push(`${candidate}: ${error?.code ?? error?.message ?? error}`);
59
+ }
60
+ }
61
+ loadError = loadErrors.length > 0 ? loadErrors[loadErrors.length - 1] : null;
62
+ // Diagnostics should name the explicitly configured gate when that is the one
63
+ // that failed — not the author-tree fallback that was tried after it.
64
+ if (ENV_GATE !== null) {
65
+ const envFailure = loadErrors.find((entry) => entry.startsWith(ENV_GATE));
66
+ if (envFailure) loadError = envFailure;
67
+ }
68
+
69
+ /** 显式配置了门却装不上:不静默放行,加载期告警一次,扫描强制走强化本地规则。 */
70
+ const gateConfigured = ENV_GATE !== null;
71
+ const forcedFallback = gateConfigured && realScan === null;
72
+ if (forcedFallback) {
73
+ try {
74
+ process.emitWarning(
75
+ `[longlive:privacy] DSH_PRIVACY_GATE 已配置但不可达(${loadError ?? 'unknown'})`
76
+ + ';已强制使用强化本地兜底规则(含密钥/令牌模式),不会静默放行。',
77
+ );
78
+ } catch {
79
+ /* warning is best effort; the scan below is what enforces the rule */
45
80
  }
46
81
  }
47
82
 
48
- /* 核心兜底:只认三类最硬的 PII,避免把普通数字(token 数、ID 号)误判。 */
83
+ /* 核心兜底 · 一类最硬的 PII:避免把普通数字(token 数、ID 号)误判。 */
49
84
  const PII_PATTERNS = [
50
85
  { kind: 'phone', re: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
51
86
  { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
52
87
  { kind: 'id-card', re: /(?<!\d)\d{17}[\dXx](?!\d)/g },
53
88
  ];
54
89
 
55
- /** 核心自带的保守扫描(不依赖任何外部文件)。 */
56
- export function fallbackScan(text) {
57
- const s = String(text ?? '');
58
- const spans = [];
59
- for (const { kind, re } of PII_PATTERNS) {
90
+ /*
91
+ * 核心兜底 · 密钥/令牌类。全部要求「像凭证」才拦,尽量不误伤普通文本:
92
+ * - sk- 前缀要求前面不是标识符字符(`task-id-1234567890` 里的 `sk-id-…` 不算);
93
+ * - `token=`/`api_key=` 这类赋值要求至少 6–8 位值,`token 数 12`、`--test` 不会命中;
94
+ * - 赋值形态允许 `[a-z0-9_]{0,32}` 前缀(RE-S3.1:`my_api_key: …`、`apiKey=…`、
95
+ * `secret token: …`),分隔符允许 `= : : ,` 与两侧空格;**纯空格不算分隔符**,
96
+ * 所以 `password reset flow` / `password 字段在 config.js 里` 不误伤;
97
+ * - PRIVATE KEY 头、AWS/GitHub/Slack 前缀是强特征,直接拦。
98
+ */
99
+ const ASSIGN_VALUE = String.raw`['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}`;
100
+ const SECRET_PATTERNS = [
101
+ // RE-S3.7: sk-/SK- 前缀大小写不敏感(连续形式;空格/Unicode 变体是 P3 残余建议)。
102
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9][A-Za-z0-9_-]{8,}/gi },
103
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*(?:_API_KEY|_APIKEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_PASSWD)\s*[=:]\s*\S+/g },
104
+ // RE-S3.1: 小写/带前缀的赋值形态(大小写不敏感):my_api_key: "…"、apiKey=…。
105
+ {
106
+ kind: 'api-key',
107
+ re: new RegExp(
108
+ String.raw`(?<![A-Za-z0-9])[A-Za-z0-9_]{0,32}`
109
+ + String.raw`(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key|token|secret|password|passwd|pwd|credential)`
110
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
111
+ 'gi',
112
+ ),
113
+ },
114
+ // RE-S3.1: 多词标签(允许空格/下划线):secret token: …、api key = …。
115
+ {
116
+ kind: 'token',
117
+ re: new RegExp(
118
+ String.raw`(?<![A-Za-z0-9])(?:api|access|secret|private|auth|refresh|session)[\s_-]+(?:key|token|secret|password)`
119
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
120
+ 'gi',
121
+ ),
122
+ },
123
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
124
+ { kind: 'token', re: /(?<![A-Za-z0-9_])(?:token|auth|access[_-]?token|refresh[_-]?token|session[_-]?token)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
125
+ { kind: 'password', re: /(?<![A-Za-z0-9_])(?:password|passwd|pwd)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}/gi },
126
+ { kind: 'bearer', re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi },
127
+ { kind: 'private-key', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
128
+ { kind: 'aws-key', re: /(?<![A-Z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![0-9A-Z])/g },
129
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}/g },
130
+ { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
131
+ ];
132
+
133
+ function scanWith(patterns, text, spans) {
134
+ for (const { kind, re } of patterns) {
60
135
  re.lastIndex = 0;
61
- let m = re.exec(s);
136
+ let m = re.exec(text);
62
137
  while (m !== null) {
63
138
  spans.push({ start: m.index, end: m.index + m[0].length, kind });
64
139
  if (m.index === re.lastIndex) re.lastIndex += 1;
65
- m = re.exec(s);
140
+ m = re.exec(text);
66
141
  }
67
142
  }
143
+ }
144
+
145
+ /** 核心自带的保守扫描(不依赖任何外部文件):PII + 凭证类,两类都算 local_only。 */
146
+ export function fallbackScan(text) {
147
+ const s = String(text ?? '');
148
+ const spans = [];
149
+ scanWith(PII_PATTERNS, s, spans);
150
+ scanWith(SECRET_PATTERNS, s, spans);
68
151
  spans.sort((a, b) => a.start - b.start);
69
- if (spans.length === 0) return { verdict: 'none', spans: [], reasons: [], source: 'core-fallback' };
152
+ if (spans.length === 0) {
153
+ return { verdict: 'none', spans: [], reasons: [], source: forcedFallback ? 'core-fallback:forced' : 'core-fallback' };
154
+ }
70
155
  return {
71
156
  verdict: 'local_only',
72
157
  spans,
73
158
  reasons: [...new Set(spans.map((x) => x.kind))],
74
- source: 'core-fallback',
159
+ source: forcedFallback ? 'core-fallback:forced' : 'core-fallback',
75
160
  };
76
161
  }
77
162
 
78
163
  /**
79
164
  * 注入前的隐私扫描(同步)。
165
+ * - 核心兜底(含凭证类)先扫,命中直接 local_only;
80
166
  * - 隐私门可用 → 调它;门抛错 → fail-closed;
81
167
  * - 门说 none 但兜底正则命中 → 仍按命中处理(并集)。
82
168
  * @param {string} text
@@ -113,6 +199,9 @@ export const privacyGateStatus = Object.freeze({
113
199
  loaded: realScan !== null,
114
200
  from: loadedFrom,
115
201
  error: realScan === null ? loadError : null,
202
+ configured: gateConfigured,
203
+ forcedFallback,
204
+ scope: 'pii+secret',
116
205
  });
117
206
 
118
207
  /** 给 chooseRecall 用的一次性参数:`options: { ...privacyDefaults }`。 */
@@ -30,6 +30,10 @@ export const FOCUS_ACTION_DETAIL_MAX = 60;
30
30
  export const LAST_CONCLUSION_MAX = 120;
31
31
  export const TIMELINE_MAX = 8;
32
32
  export const ROSTER_MAX = 256;
33
+ // R3: a declared goal older than this is no longer "what the session is doing
34
+ // right now". The board says so instead of letting the heartbeat timestamp
35
+ // masquerade as the goal's freshness.
36
+ export const FOCUS_STALE_MS = 30 * 60 * 1000;
33
37
 
34
38
  const STATE_PRIORITY = Object.freeze({ running: 0, blocked: 1, waiting: 2, idle: 3 });
35
39
 
@@ -331,9 +335,38 @@ export function createBoardBuilder(options = {}) {
331
335
  if (args.saturated === true) warnings.push('LIVE_ROWS_SATURATED');
332
336
 
333
337
  const metadata = Array.isArray(args.metadata) ? args.metadata : null;
338
+ // A host that cannot list sessions (no metadata source) leaves row origin
339
+ // unknown. A non-empty list is treated as authoritative; an empty/absent
340
+ // one falls into the fail-safe heuristic below.
341
+ const metadataAvailable = Array.isArray(metadata) && metadata.length > 0;
334
342
  const metaMap = new Map();
335
343
  for (const item of metadata ?? []) metaMap.set(item.sessionId, item);
336
344
 
345
+ /**
346
+ * R4.6: main vs subagent origin for one live row.
347
+ * With metadata: the host list is authoritative; a row missing from it is
348
+ * `unknown` (fail-safe: never counted as active/running/focus).
349
+ * Without metadata: the host contract (verified against real DSH session
350
+ * dirs on this machine) is root sessions `session-<uuid>` / subagent and
351
+ * worker sessions bare UUID ids; a bare UUID row is therefore `unknown`
352
+ * until proven otherwise, everything else stays `main` so a host that
353
+ * cannot list sessions never silently drops its rows.
354
+ */
355
+ const originOfSession = (sessionId) => {
356
+ if (typeof sessionId !== 'string' || sessionId === '') return 'unknown';
357
+ if (metadataAvailable) {
358
+ const meta = metaMap.get(sessionId) ?? null;
359
+ if (!meta) return 'unknown';
360
+ return meta.origin === 'subagent' ? 'subagent' : 'main';
361
+ }
362
+ return isBareUuidSessionId(sessionId) ? 'unknown' : 'main';
363
+ };
364
+ const originMap = new Map();
365
+ for (const entry of scan.entries) {
366
+ if (entry.sessionId) originMap.set(entry.sessionId, originOfSession(entry.sessionId));
367
+ }
368
+ const originRank = (entry) => originMap.get(entry.sessionId) ?? 'unknown';
369
+
337
370
  const liveEntries = scan.entries.filter((entry) => !entry.corrupt && entry.sessionId);
338
371
  const liveMap = new Map(liveEntries.map((entry) => [entry.sessionId, entry]));
339
372
  const liveCount = liveEntries.length;
@@ -366,26 +399,46 @@ export function createBoardBuilder(options = {}) {
366
399
  roster.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
367
400
  if (roster.length > ROSTER_MAX) roster.length = ROSTER_MAX;
368
401
 
369
- const unknownSessions = roster.filter((item) => item.unknown === true && item.origin !== 'subagent').length;
370
- const activeEntries = liveEntries.filter((entry) => ACTIVE_STATE_SET.has(entry.effectiveState) && !isSubagent(entry, metaMap));
402
+ const metadataUnknownSessions = roster.filter((item) => item.unknown === true && item.origin !== 'subagent').length;
403
+ // Active/focus set: rows we can prove are main sessions and that are not
404
+ // dead/stale/corrupt (ACTIVE_STATE_SET is exactly the declared-state band).
405
+ const activeEntries = liveEntries.filter((entry) => originRank(entry) === 'main'
406
+ && ACTIVE_STATE_SET.has(entry.effectiveState));
407
+ // Rows whose origin we cannot prove: fail-safe — never counted active or
408
+ // shown in focus, but reported as unknown instead of silently believed
409
+ // (R4.6 边界:metadata 缺失时的 subagent 行不得再抬高 running/active).
410
+ const unknownOriginEntries = liveEntries.filter((entry) => originRank(entry) === 'unknown'
411
+ && ACTIVE_STATE_SET.has(entry.effectiveState));
371
412
  // A live row we can see but cannot decode still proves a session exists.
372
413
  // DESIGN §9 counts such a row as active (state unknown), while a zero-byte
373
414
  // file is an interrupted write and is ignored (and later cleaned).
374
415
  const unreadableActive = scan.entries.filter((entry) => entry.corrupt === true
375
416
  && entry.sessionId
376
- && !isSubagent(entry, metaMap)
417
+ && originRank(entry) === 'main'
377
418
  && fileHasBytes(entry.file)).length;
378
- // Subagents are counted only in counts.subagents: they never inflate
379
- // activeSessions/unknownSessions and never appear in focus.
419
+ const unreadableUnknown = scan.entries.filter((entry) => entry.corrupt === true
420
+ && entry.sessionId
421
+ && originRank(entry) === 'unknown'
422
+ && fileHasBytes(entry.file)).length;
423
+ // Sessions we cannot classify (no metadata + child-shaped id, or a live row
424
+ // missing from the host's session list) are reported as unknown, never as
425
+ // running/main. Subagents are counted only in counts.subagents.
426
+ const unknownSessions = metadataUnknownSessions + unknownOriginEntries.length + unreadableUnknown;
380
427
  const allActive = activeEntries.length + unreadableActive;
381
428
  if (activeEntries.length > Math.max(1, maxSessions)) warnings.push('FOCUS_LIMITED');
382
- const running = liveEntries.filter((entry) => entry.state === 'running').length;
383
- const blocked = liveEntries.filter((entry) => entry.state === 'blocked').length;
429
+ // R4.7: pulse counts share the active/focus set (main sessions, not
430
+ // dead/stale/corrupt) and count the row's DECLARED state. effectiveState
431
+ // alone cannot answer this: a fresh heartbeat returns `running` for every
432
+ // row, so a live declared-blocked row would never count as blocked (the
433
+ // R4.7 first-round over-fix; RE-R47.1).
434
+ const running = activeEntries.filter((entry) => entry.state === 'running').length;
435
+ const blocked = activeEntries.filter((entry) => entry.state === 'blocked').length;
384
436
  const subagents = roster.filter((item) => item.origin === 'subagent').length;
385
437
 
386
438
  const proposals = readProposalSummaries(proposalsDir);
387
439
  const countsProposals = proposalCounts(proposals);
388
440
  const conclusions = readBridgeConclusions(bridgeDir);
441
+ const atMs = at.getTime();
389
442
 
390
443
  const focus = activeEntries
391
444
  .map((entry) => {
@@ -395,19 +448,30 @@ export function createBoardBuilder(options = {}) {
395
448
  // detail (already capped to 60 chars), skipping lifecycle-only noise.
396
449
  const conclusion = conclusions.get(entry.sessionId)
397
450
  ?? conclusionFromAction(lastAction);
451
+ // R3: focus.updatedAt is the goal's own clock; heartbeatAt must never
452
+ // pretend to be it. Rows that never declared a goal (or rows written
453
+ // before the goal clock existed) get null — unknown, not fabricated —
454
+ // and are never marked stale because there is no goal to be stale.
455
+ const goalUpdatedMs = Date.parse(entry.goalUpdatedAt ?? '');
456
+ const goalUpdatedAt = Number.isFinite(goalUpdatedMs) ? entry.goalUpdatedAt : null;
457
+ const declared = goalText !== '';
458
+ const goalAgeMs = declared && goalUpdatedAt !== null ? Math.max(0, atMs - goalUpdatedMs) : null;
459
+ const stale = goalAgeMs !== null && goalAgeMs > FOCUS_STALE_MS;
398
460
  return {
399
461
  sessionId: entry.sessionId,
400
462
  goal: goalText || '(未声明)',
401
- updatedAt: isoOrNull(entry.heartbeatAt),
463
+ updatedAt: goalUpdatedAt,
464
+ goalAgeMs,
465
+ stale,
402
466
  lastAction,
403
467
  lastConclusion: lastConclusionOf(conclusion),
404
468
  queuedCount: queuedCountOf(entry.queued),
405
469
  state: entry.effectiveState,
406
- declared: goalText !== '',
470
+ declared,
407
471
  };
408
472
  })
409
473
  .sort((a, b) => {
410
- // DESIGN §3.2: declared goal → running → freshest heartbeat.
474
+ // DESIGN §3.2: declared goal → running → freshest goal write.
411
475
  if (a.declared !== b.declared) return a.declared ? -1 : 1;
412
476
  const pa = STATE_PRIORITY[a.state] ?? 9;
413
477
  const pb = STATE_PRIORITY[b.state] ?? 9;
@@ -423,7 +487,11 @@ export function createBoardBuilder(options = {}) {
423
487
  lastAction: item.lastAction,
424
488
  lastConclusion: item.lastConclusion,
425
489
  queuedCount: item.queuedCount,
490
+ // R3.2: the stale flag is the single tree-wide signal (focus item);
491
+ // omitted when fresh so the persisted 6-field shape is unchanged.
492
+ ...(item.stale ? { stale: true } : {}),
426
493
  }));
494
+ const focusStale = focus.some((item) => item.stale === true);
427
495
 
428
496
  const timeline = countsProposals.applied
429
497
  .filter((item) => item.title !== '')
@@ -468,7 +536,9 @@ export function createBoardBuilder(options = {}) {
468
536
  activeSessions: allActive,
469
537
  unknownSessions,
470
538
  focusVisible: focus.length,
471
- stale: false,
539
+ // In-memory aggregate of R3 staleness (focus items carry stale:true).
540
+ // Never persisted: board.json shows it on the individual focus row.
541
+ stale: focusStale,
472
542
  counts: {
473
543
  active: allActive,
474
544
  running,
@@ -608,9 +678,18 @@ function cleanBoardTmp(boardFile, nowMs) {
608
678
  return removed;
609
679
  }
610
680
 
611
- function isSubagent(entry, metaMap) {
612
- const meta = metaMap.get(entry.sessionId);
613
- return meta?.origin === 'subagent';
681
+ /**
682
+ * R4.6 fail-safe: the host's child-session id shape. Real DSH session dirs on
683
+ * this machine show root/main sessions as `session-<uuid>` while spawned
684
+ * subagent/worker sessions get a bare UUID. When the host exposes no session
685
+ * metadata this is the only honest signal available, so a bare-UUID row is
686
+ * treated as *unknown origin* (reported in unknownSessions, never counted as
687
+ * running/active/main). Requires metadata to be certain: with metadata the
688
+ * host list wins and this heuristic is not consulted.
689
+ */
690
+ export function isBareUuidSessionId(sessionId) {
691
+ return typeof sessionId === 'string'
692
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId);
614
693
  }
615
694
 
616
695
  function verifyBoardWrite(file, board) {
@@ -623,7 +702,8 @@ function verifyBoardWrite(file, board) {
623
702
 
624
703
  /** Format one focus row for the fixed hint template. */
625
704
  export function renderFocusLine(item) {
626
- return `- ${item.sessionId} ${item.state ?? ''} · 目标${quoted(item.goal, FOCUS_GOAL_MAX)} · ${timeOf(item.updatedAt)}`;
705
+ const stale = item?.stale === true ? ' · stale(目标超 30 分钟未更新)' : '';
706
+ return `- ${item.sessionId} ${item.state ?? ''} · 目标${quoted(item.goal, FOCUS_GOAL_MAX)} · ${timeOf(item.updatedAt)}${stale}`;
627
707
  }
628
708
 
629
709
  export function timeOf(iso) {
@@ -180,6 +180,17 @@ function relativeMinutes(iso, nowMs) {
180
180
  }
181
181
 
182
182
  const RECENT_ACTION_MS = 10 * 60 * 1000;
183
+ // R3.4: the stale flag must be visible in the shared hint, not only in
184
+ // board.json. Same signal as focus.stale:true — the hint never re-judges it.
185
+ const FOCUS_STALE_MINUTES = 30;
186
+
187
+ /** User-visible stale suffix for one focus row; '' when the goal is fresh. */
188
+ function focusStaleMarker(item, nowMs) {
189
+ if (!item || item.stale !== true) return '';
190
+ const age = relativeMinutes(item.updatedAt, nowMs);
191
+ const goalAge = age === null || age === '刚刚' ? '目标超 30 分钟未更新' : `目标 ${age}写入,超 ${FOCUS_STALE_MINUTES} 分钟未更新`;
192
+ return ` · stale(${goalAge})`;
193
+ }
183
194
 
184
195
  /**
185
196
  * L2 presence upgrade: sessions other than this one whose `lastAction.at` moved
@@ -238,6 +249,9 @@ export function renderHint(board, options = {}) {
238
249
  // Presence upgrade: 12-char prefix instead of a full opaque session id.
239
250
  const sessionId = sessionPrefix(item.sessionId, 12);
240
251
  let line = `- ${sessionId} 目标「${goal}」`;
252
+ // R3.4: stale goals must be visible to the other session, with the goal's
253
+ // own write time — never silently dropped from the shared hint.
254
+ line += focusStaleMarker(item, nowMs);
241
255
  const action = item.lastAction && typeof item.lastAction === 'object' ? item.lastAction : null;
242
256
  if (action) {
243
257
  const kind = neutralizeEnvelope(oneLine(action.kind, 24));
@@ -607,8 +607,13 @@ export class RouterService {
607
607
  pulse: {
608
608
  ...(board.pulse ?? {}),
609
609
  active: board.activeSessions ?? 0,
610
- running: 0,
611
- blocked: this.panelState === 'blocked' ? 1 : 0,
610
+ // R4.2: same projection as board.json — never a second, hardcoded
611
+ // number. Before this, status.json said 0 while board.json said 34.
612
+ running: board.pulse?.running ?? 0,
613
+ // R4.7: blocked is the same pulse field as the board's — never the
614
+ // Router's own panelState wearing the same name with another value.
615
+ // Router state is already reported as status.state.
616
+ blocked: board.pulse?.blocked ?? 0,
612
617
  unknownSessions: board.unknownSessions ?? 0,
613
618
  queuedProposals: proposals.queue?.depth ?? 0,
614
619
  approvedProposals: proposalCounts.queued ?? 0,
@@ -1567,7 +1572,18 @@ export class RouterService {
1567
1572
  if (this.deps.installListener === false) return;
1568
1573
  if (typeof this.ctx.on !== 'function') return;
1569
1574
  try {
1570
- const disposer = this.ctx.on('session/event', (event) => this.handleSessionEvent(event), { prepend: false });
1575
+ // R4.4: the real host emits `session/event` as TWO positional args
1576
+ // `(session, event)` (recorded DSH logs, 2026-11-06), while several
1577
+ // shipped tests drive the legacy one-arg envelope. Accept both arities:
1578
+ // two-arg -> event is arg 2 and arg 1 is the session; one-arg -> arg 1 is
1579
+ // the event (which may still carry `.session` inside).
1580
+ const listener = (...args) => {
1581
+ const twoArg = args.length >= 2 && args[1] != null;
1582
+ const event = twoArg ? args[1] : args[0];
1583
+ const session = twoArg ? args[0] : null;
1584
+ return this.handleSessionEvent(event, session);
1585
+ };
1586
+ const disposer = this.ctx.on('session/event', listener, { prepend: false });
1571
1587
  if (typeof disposer === 'function') this.listenerDisposers.push(disposer);
1572
1588
  } catch (error) {
1573
1589
  this.#log('warn', `session/event listener install failed: ${errorMessage(error)}`);
@@ -1699,9 +1715,9 @@ export class RouterService {
1699
1715
  }
1700
1716
  }
1701
1717
 
1702
- #onTurnEnd(event, via) {
1718
+ #onTurnEnd(event, via, sessionArg = null) {
1703
1719
  if (this.disposed) return;
1704
- const session = this.#sessionOfEvent(event);
1720
+ const session = sessionArg ?? this.#sessionOfEvent(event);
1705
1721
  const id = sessionIdOf(session) ?? sessionIdOf(event);
1706
1722
  if (!id) return;
1707
1723
  const header = session?.header ?? {};
@@ -1740,20 +1756,20 @@ export class RouterService {
1740
1756
  }
1741
1757
  }
1742
1758
 
1743
- #handleLiveEvent(event) {
1759
+ #handleLiveEvent(event, sessionArg = null) {
1744
1760
  const type = event?.type;
1745
1761
  if (type === 'session/created') {
1746
- this.#onSessionCreated(this.#sessionOfEvent(event), 'session/event');
1762
+ this.#onSessionCreated(sessionArg ?? this.#sessionOfEvent(event), 'session/event');
1747
1763
  return true;
1748
1764
  }
1749
1765
  if (type === 'turn/end') {
1750
- this.#onTurnEnd(event, 'turn/end');
1766
+ this.#onTurnEnd(event, 'turn/end', sessionArg);
1751
1767
  return true;
1752
1768
  }
1753
1769
  if (type === 'agent/status') {
1754
1770
  const status = event?.data?.state ?? event?.data?.status ?? event?.status;
1755
1771
  if (status === 'idle') {
1756
- this.#onTurnEnd(event, 'agent/status');
1772
+ this.#onTurnEnd(event, 'agent/status', sessionArg);
1757
1773
  return true;
1758
1774
  }
1759
1775
  }
@@ -1764,10 +1780,10 @@ export class RouterService {
1764
1780
  * turn/end 唤醒:只认 reason.kind === 'completed',2 秒防抖 + 5 分钟合并。
1765
1781
  * 返回 {scheduled, coalesced?, debounced?, ignored?},方便单测。
1766
1782
  */
1767
- handleSessionEvent(event) {
1783
+ handleSessionEvent(event, session = null) {
1768
1784
  // L1 heartbeat runs for every turn boundary, independent of reflectOnTurn
1769
1785
  // and of reason.kind (an aborted turn still ends).
1770
- try { this.#handleLiveEvent(event); } catch (error) {
1786
+ try { this.#handleLiveEvent(event, session); } catch (error) {
1771
1787
  this.#log('warn', `live heartbeat failed: ${errorMessage(error)}`);
1772
1788
  }
1773
1789
  const type = event?.type;