principles-disciple 1.7.8 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/core/config.d.ts +2 -0
  2. package/dist/core/session-tracker.d.ts +3 -1
  3. package/dist/core/session-tracker.js +12 -3
  4. package/dist/hooks/llm.d.ts +1 -0
  5. package/dist/hooks/llm.js +17 -70
  6. package/dist/hooks/progressive-trust-gate.d.ts +1 -0
  7. package/dist/hooks/progressive-trust-gate.js +45 -0
  8. package/dist/hooks/prompt.d.ts +2 -0
  9. package/dist/hooks/prompt.js +27 -6
  10. package/dist/hooks/subagent.js +2 -2
  11. package/dist/http/principles-console-route.js +114 -0
  12. package/dist/service/empathy-observer-manager.d.ts +46 -10
  13. package/dist/service/empathy-observer-manager.js +249 -64
  14. package/dist/service/evolution-worker.js +1 -0
  15. package/dist/service/health-query-service.d.ts +170 -0
  16. package/dist/service/health-query-service.js +662 -0
  17. package/dist/service/nocturnal-runtime.d.ts +2 -2
  18. package/dist/service/nocturnal-runtime.js +75 -4
  19. package/dist/service/nocturnal-service.js +2 -2
  20. package/dist/service/subagent-workflow/empathy-observer-workflow-manager.d.ts +48 -0
  21. package/dist/service/subagent-workflow/empathy-observer-workflow-manager.js +480 -0
  22. package/dist/service/subagent-workflow/index.d.ts +4 -0
  23. package/dist/service/subagent-workflow/index.js +3 -0
  24. package/dist/service/subagent-workflow/runtime-direct-driver.d.ts +77 -0
  25. package/dist/service/subagent-workflow/runtime-direct-driver.js +75 -0
  26. package/dist/service/subagent-workflow/types.d.ts +259 -0
  27. package/dist/service/subagent-workflow/types.js +11 -0
  28. package/dist/service/subagent-workflow/workflow-store.d.ts +26 -0
  29. package/dist/service/subagent-workflow/workflow-store.js +165 -0
  30. package/dist/tools/deep-reflect.js +2 -2
  31. package/openclaw.plugin.json +6 -1
  32. package/package.json +3 -3
@@ -78,6 +78,8 @@ export interface PainSettings {
78
78
  deep_reflection?: DeepReflectionSettings;
79
79
  empathy_engine?: {
80
80
  enabled?: boolean;
81
+ /** Shadow mode: also run EmpathyObserverWorkflowManager alongside legacy path */
82
+ helper_empathy_enabled?: boolean;
81
83
  dedupe_window_ms?: number;
82
84
  penalties?: {
83
85
  mild?: number;
@@ -8,6 +8,8 @@ export interface TokenUsage {
8
8
  }
9
9
  export interface SessionState {
10
10
  sessionId: string;
11
+ sessionKey?: string;
12
+ trigger?: string;
11
13
  workspaceDir?: string;
12
14
  toolReadsByFile: Record<string, number>;
13
15
  llmTurns: number;
@@ -40,7 +42,7 @@ export declare function initPersistence(stateDir: string): void;
40
42
  */
41
43
  export declare function flushAllSessions(): void;
42
44
  export declare function trackToolRead(sessionId: string, filePath: string, workspaceDir?: string): SessionState;
43
- export declare function trackLlmOutput(sessionId: string, usage: TokenUsage | undefined, config?: PainConfig, workspaceDir?: string): SessionState;
45
+ export declare function trackLlmOutput(sessionId: string, usage: TokenUsage | undefined, config?: PainConfig, workspaceDir?: string, sessionKey?: string, trigger?: string): SessionState;
44
46
  /**
45
47
  * Tracks physical friction based on tool execution failures.
46
48
  */
@@ -111,11 +111,13 @@ export function flushAllSessions() {
111
111
  persistSession(state);
112
112
  }
113
113
  }
114
- function getOrCreateSession(sessionId, workspaceDir) {
114
+ function getOrCreateSession(sessionId, workspaceDir, sessionKey, trigger) {
115
115
  let state = sessions.get(sessionId);
116
116
  if (!state) {
117
117
  state = {
118
118
  sessionId,
119
+ sessionKey,
120
+ trigger,
119
121
  workspaceDir,
120
122
  toolReadsByFile: {},
121
123
  llmTurns: 0,
@@ -143,6 +145,13 @@ function getOrCreateSession(sessionId, workspaceDir) {
143
145
  if (workspaceDir && !state.workspaceDir) {
144
146
  state.workspaceDir = workspaceDir;
145
147
  }
148
+ // Update sessionKey and trigger if provided (they may be more recent)
149
+ if (sessionKey && !state.sessionKey) {
150
+ state.sessionKey = sessionKey;
151
+ }
152
+ if (trigger && !state.trigger) {
153
+ state.trigger = trigger;
154
+ }
146
155
  return state;
147
156
  }
148
157
  function ensureGfiLedger(state) {
@@ -158,8 +167,8 @@ export function trackToolRead(sessionId, filePath, workspaceDir) {
158
167
  touchActivity(state);
159
168
  return state;
160
169
  }
161
- export function trackLlmOutput(sessionId, usage, config, workspaceDir) {
162
- const state = getOrCreateSession(sessionId, workspaceDir);
170
+ export function trackLlmOutput(sessionId, usage, config, workspaceDir, sessionKey, trigger) {
171
+ const state = getOrCreateSession(sessionId, workspaceDir, sessionKey, trigger);
163
172
  state.llmTurns += 1;
164
173
  touchActivity(state);
165
174
  if (usage) {
@@ -7,6 +7,7 @@ export interface EmpathySignal {
7
7
  mode?: 'structured' | 'legacy_tag';
8
8
  }
9
9
  export declare function extractEmpathySignal(text: string): EmpathySignal;
10
+ export declare function isEmpathyAuditPayload(text: string): boolean;
10
11
  export declare function handleLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext & {
11
12
  workspaceDir?: string;
12
13
  }): void;
package/dist/hooks/llm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { trackFriction, trackLlmOutput, recordThinkingCheckpoint, resetFriction } from '../core/session-tracker.js';
3
+ import { trackLlmOutput, recordThinkingCheckpoint, resetFriction } from '../core/session-tracker.js';
4
4
  import { writePainFlag } from '../core/pain.js';
5
5
  import { ControlUiDatabase } from '../core/control-ui-db.js';
6
6
  import { DetectionService } from '../core/detection-service.js';
@@ -175,6 +175,18 @@ function applyRateLimit(sessionId, runId, score, config) {
175
175
  empathyRateState.set(sessionId, prev);
176
176
  return allowed;
177
177
  }
178
+ export function isEmpathyAuditPayload(text) {
179
+ if (!text || typeof text !== 'string')
180
+ return false;
181
+ const trimmed = text.trim();
182
+ if (/^\{[\s\S]*"damageDetected"[\s\S]*\}$/.test(trimmed))
183
+ return true;
184
+ if (/^<empathy\s+([^>]*)\/?>/i.test(trimmed))
185
+ return true;
186
+ if (/^\s*\[EMOTIONAL_DAMAGE_DETECTED(?::(mild|moderate|severe))?\]\s*$/i.test(trimmed))
187
+ return true;
188
+ return false;
189
+ }
178
190
  export function handleLlmOutput(event, ctx) {
179
191
  if (!ctx.workspaceDir || !ctx.sessionId)
180
192
  return;
@@ -182,7 +194,7 @@ export function handleLlmOutput(event, ctx) {
182
194
  const config = wctx.config;
183
195
  const eventLog = wctx.eventLog;
184
196
  // Track this turn in the core session memory
185
- const state = trackLlmOutput(ctx.sessionId, event.usage, config, ctx.workspaceDir);
197
+ const state = trackLlmOutput(ctx.sessionId, event.usage, config, ctx.workspaceDir, ctx.sessionKey, ctx.trigger);
186
198
  // We need actual assistant text to analyze
187
199
  if (!event.assistantTexts || event.assistantTexts.length === 0)
188
200
  return;
@@ -207,14 +219,15 @@ export function handleLlmOutput(event, ctx) {
207
219
  ctx.logger?.warn?.(`[PD:LLM] Failed to persist assistant turn to trajectory: ${String(error)}`);
208
220
  }
209
221
  // ── Track B: Semantic Pain Detection (V1.3.0 Funnel) ──
222
+ const detectionText = isEmpathyAuditPayload(text) ? '' : text;
210
223
  const detectionService = DetectionService.get(wctx.stateDir);
211
- const detection = detectionService.detect(text);
224
+ const detection = detectionService.detect(detectionText);
212
225
  if (detection.detected) {
213
226
  eventLog.recordRuleMatch(ctx.sessionId, {
214
227
  ruleId: detection.ruleId || detection.source,
215
228
  layer: detection.source === 'l1_exact' ? 'L1' : (detection.source === 'l2_cache' ? 'L2' : 'L3'),
216
229
  severity: detection.severity || 0,
217
- textPreview: text.substring(0, 100)
230
+ textPreview: detectionText.substring(0, 100)
218
231
  });
219
232
  }
220
233
  let painScore = detection.detected ? (detection.severity || 0) : 0;
@@ -224,73 +237,7 @@ export function handleLlmOutput(event, ctx) {
224
237
  let matchedReason = detection.detected
225
238
  ? `Agent triggered pain detection (Source: ${detection.source}${detection.ruleId ? `, Rule: ${detection.ruleId}` : ''})`
226
239
  : '';
227
- // empathy sub-pipeline (enabled by default)
228
- const empathyEnabled = config.get('empathy_engine.enabled');
229
- if (empathyEnabled !== false) {
230
- if (signal.detected) {
231
- const dedupeWindow = Number(config.get('empathy_engine.dedupe_window_ms') ?? 60000);
232
- const deduped = shouldDedupe(ctx.sessionId, event.runId, signal, dedupeWindow);
233
- if (!deduped) {
234
- const baseScore = mapSeverityToPenalty(signal.severity, config);
235
- const weightedScore = Math.round(baseScore * signal.confidence);
236
- const calibrationFactor = resolveCalibrationFactor(event, config);
237
- const calibratedScore = Math.round(weightedScore * calibrationFactor);
238
- const boundedScore = applyRateLimit(ctx.sessionId, event.runId, calibratedScore, config);
239
- if (boundedScore > 0) {
240
- trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir, { source: 'user_empathy' });
241
- try {
242
- wctx.trajectory?.recordPainEvent?.({
243
- sessionId: ctx.sessionId,
244
- source: 'user_empathy',
245
- score: boundedScore,
246
- reason: signal.reason || 'Assistant self-reported user emotional distress.',
247
- severity: signal.severity,
248
- origin: 'assistant_self_report',
249
- confidence: signal.confidence,
250
- });
251
- }
252
- catch (error) {
253
- ctx.logger?.warn?.(`[PD:LLM] Failed to persist empathy pain event to trajectory: ${String(error)}`);
254
- }
255
- // Generate unique event ID for rollback support
256
- const eventId = `emp_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
257
- eventLog.recordPainSignal(ctx.sessionId, {
258
- score: boundedScore,
259
- source: 'user_empathy',
260
- reason: signal.reason || 'Assistant self-reported user emotional distress.',
261
- isRisky: false,
262
- origin: 'assistant_self_report',
263
- severity: signal.severity,
264
- confidence: signal.confidence,
265
- detection_mode: signal.mode,
266
- deduped: false,
267
- trigger_text_excerpt: text.substring(0, 120),
268
- raw_score: weightedScore,
269
- calibrated_score: calibratedScore,
270
- eventId,
271
- });
272
- }
273
- }
274
- else {
275
- eventLog.recordPainSignal(ctx.sessionId, {
276
- score: 0,
277
- source: 'user_empathy',
278
- reason: signal.reason || 'Deduped empathy signal.',
279
- isRisky: false,
280
- origin: 'assistant_self_report',
281
- severity: signal.severity,
282
- confidence: signal.confidence,
283
- detection_mode: signal.mode,
284
- deduped: true,
285
- trigger_text_excerpt: text.substring(0, 120),
286
- raw_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence),
287
- calibrated_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence * resolveCalibrationFactor(event, config))
288
- });
289
- }
290
- }
291
- }
292
240
  // ═══ Natural Language Rollback Detection ═══
293
- // Detect [EMPATHY_ROLLBACK_REQUEST] tag and trigger rollback
294
241
  const rollbackMatch = text.match(/^\s*\[EMPATHY_ROLLBACK_REQUEST\]\s*$/m);
295
242
  if (rollbackMatch) {
296
243
  const eventId = eventLog.getLastEmpathyEventId(ctx.sessionId);
@@ -48,4 +48,5 @@ export declare function checkProgressiveTrustGate(event: PluginHookBeforeToolCal
48
48
  sessionId?: string;
49
49
  }, profile?: {
50
50
  risk_paths: string[];
51
+ core_governance_files?: string[];
51
52
  }): PluginHookBeforeToolCallResult | void;
@@ -22,6 +22,46 @@
22
22
  */
23
23
  import { checkEvolutionGate } from '../core/evolution-engine.js';
24
24
  import { recordGateBlockAndReturn } from './gate-block-helper.js';
25
+ // ═══ P-16: Core Governance Files — Exempt from all Blocking ═══
26
+ // 这些文件是团队协作的基础,必须始终放行,不受 GFI 和 Risk Path 限制
27
+ // 可通过 PROFILE.core_governance_files 扩展(merge 而非覆盖)
28
+ const DEFAULT_CORE_GOVERNANCE_PATTERNS = [
29
+ 'PLAN.md',
30
+ 'AGENTS.md',
31
+ 'VERSION.md',
32
+ '.team/',
33
+ 'MEMORY.md',
34
+ 'SOUL.md',
35
+ 'IDENTITY.md',
36
+ 'USER.md',
37
+ 'HEARTBEAT.md',
38
+ 'BOOTSTRAP.md',
39
+ 'PRINCIPLES.md',
40
+ 'TEAM_ROLE.md',
41
+ 'REPAIR_OPERATING_PROMPT.md',
42
+ ];
43
+ /**
44
+ * Get effective core governance patterns from PROFILE config, merged with defaults.
45
+ * PROFILE.core_governance_files extends (not replaces) the default list.
46
+ */
47
+ function getCoreGovernancePatterns(profile) {
48
+ const base = DEFAULT_CORE_GOVERNANCE_PATTERNS;
49
+ const extra = profile?.core_governance_files ?? [];
50
+ return Array.from(new Set([...base, ...extra]));
51
+ }
52
+ /**
53
+ * Check if a file path matches a core governance pattern.
54
+ * Core governance files are exempt from all gate blocking (P-16).
55
+ */
56
+ function isCoreGovernanceFile(filePath, corePatterns) {
57
+ if (!filePath)
58
+ return false;
59
+ const patterns = corePatterns ?? DEFAULT_CORE_GOVERNANCE_PATTERNS;
60
+ const normalized = filePath.replace(/\\/g, '/');
61
+ return patterns.some(pattern => pattern.endsWith('/')
62
+ ? normalized.includes(pattern)
63
+ : normalized.endsWith(pattern) || normalized.includes(`/${pattern}`));
64
+ }
25
65
  /**
26
66
  * Build EP gate rejection reason
27
67
  */
@@ -54,6 +94,11 @@ function block(filePath, reason, wctx, toolName, logger, sessionId) {
54
94
  * @returns PluginHookBeforeToolCallResult to block, or undefined to allow
55
95
  */
56
96
  export function checkProgressiveTrustGate(event, wctx, relPath, risky, lineChanges, logger, ctx, profile) {
97
+ // P-16: Core governance files are exempt from all gate blocking
98
+ if (isCoreGovernanceFile(relPath, getCoreGovernancePatterns(profile))) {
99
+ logger.info?.(`[PD_GATE:P-16] Core governance file exempt — bypass all gates: ${relPath}`);
100
+ return;
101
+ }
57
102
  // EP is the only gate now - use actual gate decision
58
103
  if (!ctx.workspaceDir) {
59
104
  logger.warn?.('[PD_GATE] No workspaceDir, skipping EP gate check');
@@ -17,6 +17,8 @@ interface PromptHookApi {
17
17
  };
18
18
  empathy_engine?: {
19
19
  enabled?: boolean;
20
+ /** Shadow mode: also run EmpathyObserverWorkflowManager alongside legacy path */
21
+ helper_empathy_enabled?: boolean;
20
22
  };
21
23
  };
22
24
  runtime: EmpathyObserverApi['runtime'];
@@ -6,6 +6,7 @@ import { defaultContextConfig } from '../types.js';
6
6
  import { classifyTask } from '../core/local-worker-routing.js';
7
7
  import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingMemoryToInjection, autoCompressFocus, safeReadCurrentFocus } from '../core/focus-history.js';
8
8
  import { empathyObserverManager } from '../service/empathy-observer-manager.js';
9
+ import { EmpathyObserverWorkflowManager, empathyObserverWorkflowSpec } from '../service/subagent-workflow/index.js';
9
10
  import { PathResolver } from '../core/path-resolver.js';
10
11
  /**
11
12
  * OpenClaw API Prompt Hook
@@ -414,11 +415,13 @@ You are a **self-evolving AI agent** powered by Principles Disciple.
414
415
  - If you need self-inspection, prioritize the worker entry pointed by PathResolver key: EVOLUTION_WORKER
415
416
  `;
416
417
  // ──── 2. Evolution Directive (always on, highest priority) - stays in prependContext ────
417
- // NOTE: active evolution task prompt is injected from EVOLUTION_QUEUE for active tasks
418
- // NOT used for Phase 3 eligibility decisions
419
- // EVOLUTION_DIRECTIVE.json is a compatibility-only display artifact
420
- // Phase 3 eligibility uses only queue and evolution (see phase3-input-filter.ts)
421
418
  let activeEvolutionTaskPrompt = '';
419
+ const empathySilenceConstraint = `
420
+ ### 【EMPATHY OUTPUT RESTRICTION】
421
+ Do NOT output empathy diagnostic text in JSON, XML, or tag format.
422
+ Do NOT include "damageDetected", "severity", "confidence", or "empathy" fields in your output.
423
+ The empathy observer subagent handles pain detection independently.
424
+ `.trim();
422
425
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
423
426
  if (fs.existsSync(queuePath)) {
424
427
  try {
@@ -498,7 +501,9 @@ REQUIRED ACTION:
498
501
  }
499
502
  }
500
503
  // Inject queue-derived evolution task at the front of prependContext
501
- if (activeEvolutionTaskPrompt) {
504
+ // Skip for minimal mode (heartbeat / subagent / observer sessions) to avoid
505
+ // polluting empathy observer prompts and other internal subagent sessions.
506
+ if (activeEvolutionTaskPrompt && !isMinimalMode) {
502
507
  prependContext = activeEvolutionTaskPrompt + prependContext;
503
508
  }
504
509
  // ─────────────────────────────────────────────────4. Empathy Observer Spawn (async sidecar)
@@ -506,7 +511,23 @@ REQUIRED ACTION:
506
511
  const latestUserMessage = extractLatestUserMessage(event.messages);
507
512
  const isAgentToAgent = latestUserMessage.includes('sourceSession=agent:') || sessionId?.includes(':subagent:') === true;
508
513
  if (trigger === 'user' && sessionId && api && !isAgentToAgent) {
509
- empathyObserverManager.spawn(api, sessionId, latestUserMessage).catch((err) => api.logger.warn(String(err)));
514
+ prependContext = '### BEHAVIORAL_CONSTRAINTS\n' + empathySilenceConstraint + '\n\n' + prependContext;
515
+ empathyObserverManager.spawn(api, sessionId, latestUserMessage, workspaceDir).catch((err) => api.logger.warn(String(err)));
516
+ if (api.config?.empathy_engine?.helper_empathy_enabled === true && workspaceDir) {
517
+ // Cast required because SDK SubagentRunParams lacks expectsCompletionMessage
518
+ // which is supported by the actual OpenClaw runtime
519
+ const shadowManager = new EmpathyObserverWorkflowManager({
520
+ workspaceDir,
521
+ logger: api.logger,
522
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
523
+ subagent: api.runtime.subagent,
524
+ });
525
+ shadowManager.startWorkflow(empathyObserverWorkflowSpec, {
526
+ parentSessionId: sessionId,
527
+ workspaceDir,
528
+ taskInput: latestUserMessage,
529
+ }).catch((err) => api.logger.warn(`[PD:ShadowEmpathy] workflow failed: ${String(err)}`));
530
+ }
510
531
  }
511
532
  // ──── 5. Heartbeat-specific checklist ────
512
533
  if (trigger === 'heartbeat') {
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import { writePainFlag } from '../core/pain.js';
3
3
  import { WorkspaceContext } from '../core/workspace-context.js';
4
- import { empathyObserverManager } from '../service/empathy-observer-manager.js';
4
+ import { empathyObserverManager, isEmpathyObserverSession } from '../service/empathy-observer-manager.js';
5
5
  import { acquireQueueLock } from '../service/evolution-worker.js';
6
6
  import { recordEvolutionSuccess } from '../core/evolution-engine.js';
7
7
  const COMPLETION_RETRY_DELAY_MS = 250;
@@ -125,7 +125,7 @@ export async function handleSubagentEnded(event, ctx) {
125
125
  return;
126
126
  const wctx = WorkspaceContext.fromHookContext(ctx);
127
127
  const logger = ctx.api?.logger ?? console;
128
- if (targetSessionKey?.startsWith('empathy_obs:')) {
128
+ if (isEmpathyObserverSession(targetSessionKey || '')) {
129
129
  await empathyObserverManager.reap(ctx.api, targetSessionKey, workspaceDir);
130
130
  return;
131
131
  }
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { ControlUiQueryService } from '../service/control-ui-query-service.js';
4
4
  import { getEvolutionQueryService } from '../service/evolution-query-service.js';
5
+ import { HealthQueryService } from '../service/health-query-service.js';
5
6
  import { TrajectoryRegistry } from '../core/trajectory.js';
6
7
  import { getCentralDatabase } from '../service/central-database.js';
7
8
  const ROUTE_PREFIX = '/plugins/principles';
@@ -406,6 +407,119 @@ function handleApiRoute(api, pathname, req, res) {
406
407
  evoService.dispose();
407
408
  }
408
409
  }
410
+ // === Health Query API (v1.1 new endpoints) ===
411
+ const healthService = () => {
412
+ const workspaceDir = api.resolvePath('.');
413
+ return new HealthQueryService(workspaceDir);
414
+ };
415
+ if (pathname === `${API_PREFIX}/overview/health` && method === 'GET') {
416
+ const hs = healthService();
417
+ try {
418
+ json(res, 200, hs.getOverviewHealth());
419
+ return true;
420
+ }
421
+ catch (error) {
422
+ api.logger.warn(`[PD:ControlUI] Health overview failed: ${String(error)}`);
423
+ json(res, 500, { error: 'internal_error', message: String(error) });
424
+ return true;
425
+ }
426
+ finally {
427
+ hs.dispose();
428
+ }
429
+ }
430
+ if (pathname === `${API_PREFIX}/evolution/principles` && method === 'GET') {
431
+ const hs = healthService();
432
+ try {
433
+ json(res, 200, hs.getEvolutionPrinciples());
434
+ return true;
435
+ }
436
+ catch (error) {
437
+ api.logger.warn(`[PD:ControlUI] Evolution principles failed: ${String(error)}`);
438
+ json(res, 500, { error: 'internal_error', message: String(error) });
439
+ return true;
440
+ }
441
+ finally {
442
+ hs.dispose();
443
+ }
444
+ }
445
+ if (pathname === `${API_PREFIX}/feedback/gfi` && method === 'GET') {
446
+ const hs = healthService();
447
+ try {
448
+ json(res, 200, hs.getFeedbackGfi());
449
+ return true;
450
+ }
451
+ catch (error) {
452
+ api.logger.warn(`[PD:ControlUI] Feedback GFI failed: ${String(error)}`);
453
+ json(res, 500, { error: 'internal_error', message: String(error) });
454
+ return true;
455
+ }
456
+ finally {
457
+ hs.dispose();
458
+ }
459
+ }
460
+ if (pathname === `${API_PREFIX}/feedback/empathy-events` && method === 'GET') {
461
+ const hs = healthService();
462
+ try {
463
+ const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined;
464
+ json(res, 200, hs.getFeedbackEmpathyEvents(limit));
465
+ return true;
466
+ }
467
+ catch (error) {
468
+ api.logger.warn(`[PD:ControlUI] Feedback empathy events failed: ${String(error)}`);
469
+ json(res, 500, { error: 'internal_error', message: String(error) });
470
+ return true;
471
+ }
472
+ finally {
473
+ hs.dispose();
474
+ }
475
+ }
476
+ if (pathname === `${API_PREFIX}/feedback/gate-blocks` && method === 'GET') {
477
+ const hs = healthService();
478
+ try {
479
+ const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined;
480
+ json(res, 200, hs.getFeedbackGateBlocks(limit));
481
+ return true;
482
+ }
483
+ catch (error) {
484
+ api.logger.warn(`[PD:ControlUI] Feedback gate blocks failed: ${String(error)}`);
485
+ json(res, 500, { error: 'internal_error', message: String(error) });
486
+ return true;
487
+ }
488
+ finally {
489
+ hs.dispose();
490
+ }
491
+ }
492
+ if (pathname === `${API_PREFIX}/gate/stats` && method === 'GET') {
493
+ const hs = healthService();
494
+ try {
495
+ json(res, 200, hs.getGateStats());
496
+ return true;
497
+ }
498
+ catch (error) {
499
+ api.logger.warn(`[PD:ControlUI] Gate stats failed: ${String(error)}`);
500
+ json(res, 500, { error: 'internal_error', message: String(error) });
501
+ return true;
502
+ }
503
+ finally {
504
+ hs.dispose();
505
+ }
506
+ }
507
+ if (pathname === `${API_PREFIX}/gate/blocks` && method === 'GET') {
508
+ const hs = healthService();
509
+ try {
510
+ const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined;
511
+ json(res, 200, hs.getGateBlocks(limit));
512
+ return true;
513
+ }
514
+ catch (error) {
515
+ api.logger.warn(`[PD:ControlUI] Gate blocks failed: ${String(error)}`);
516
+ json(res, 500, { error: 'internal_error', message: String(error) });
517
+ return true;
518
+ }
519
+ finally {
520
+ hs.dispose();
521
+ }
522
+ }
409
523
  if (pathname === `${API_PREFIX}/export/corrections` && method === 'GET') {
410
524
  try {
411
525
  const mode = url.searchParams.get('mode') === 'redacted' ? 'redacted' : 'raw';
@@ -13,7 +13,15 @@ export interface EmpathyObserverApi {
13
13
  lane?: string;
14
14
  deliver?: boolean;
15
15
  idempotencyKey?: string;
16
+ expectsCompletionMessage?: boolean;
16
17
  }) => Promise<unknown>;
18
+ waitForRun: (params: {
19
+ runId: string;
20
+ timeoutMs?: number;
21
+ }) => Promise<{
22
+ status: 'ok' | 'error' | 'timeout';
23
+ error?: string;
24
+ }>;
17
25
  getSessionMessages: (params: {
18
26
  sessionKey: string;
19
27
  limit?: number;
@@ -21,6 +29,10 @@ export interface EmpathyObserverApi {
21
29
  messages: unknown[];
22
30
  assistantTexts?: string[];
23
31
  }>;
32
+ deleteSession: (params: {
33
+ sessionKey: string;
34
+ deleteTranscript?: boolean;
35
+ }) => Promise<void>;
24
36
  };
25
37
  };
26
38
  logger: PluginLogger;
@@ -28,21 +40,44 @@ export interface EmpathyObserverApi {
28
40
  export declare class EmpathyObserverManager {
29
41
  private static instance;
30
42
  private sessionLocks;
43
+ private activeRuns;
44
+ private completedSessions;
31
45
  private constructor();
32
46
  static getInstance(): EmpathyObserverManager;
33
47
  /**
34
- * Probe whether the subagent runtime is actually functional.
35
- * api.runtime.subagent always exists (it's a Proxy), but in embedded mode
36
- * every method throws "only available during a gateway request".
37
- * We cache the result to avoid repeated probing.
48
+ * Build a safe session key for empathy observer
49
+ * Format: agent:main:subagent:empathy-obs-{safeParentSessionId}-{timestamp}
38
50
  */
39
- private subagentAvailableCache;
40
- private isSubagentAvailable;
51
+ buildEmpathyObserverSessionKey(parentSessionId: string): string;
52
+ /**
53
+ * Check if a session key is an empathy observer session
54
+ */
55
+ isObserverSession(sessionKey: string): boolean;
56
+ private markCompleted;
57
+ private isCompleted;
58
+ private isActive;
59
+ private getActiveMetadata;
41
60
  shouldTrigger(api: EmpathyObserverApi | null | undefined, sessionId: string): boolean;
42
- spawn(api: EmpathyObserverApi | null | undefined, sessionId: string, userMessage: string): Promise<string | null>;
43
- reap(api: EmpathyObserverApi | null | undefined, targetSessionKey: string, workspaceDir: string): Promise<void>;
44
- private isObserverSession;
45
- private extractParentSessionId;
61
+ spawn(api: EmpathyObserverApi | null | undefined, sessionId: string, userMessage: string, workspaceDir?: string): Promise<string | null>;
62
+ /**
63
+ * Main回收链路: 使用 waitForRun 驱动回收
64
+ * 仅在 ok 时回收; timeout/exception 保留 session 由 fallback 处理
65
+ */
66
+ private finalizeRun;
67
+ /**
68
+ * 统一回收入口: reap + deleteSession + 清理状态
69
+ */
70
+ private reapBySession;
71
+ /**
72
+ * Fallback回收: 由 subagent_ended 触发
73
+ * 仅在主链路未处理时执行补救回收
74
+ */
75
+ reap(api: EmpathyObserverApi | null | undefined, targetSessionKey: string, workspaceDir?: string): Promise<void>;
76
+ private cleanupState;
77
+ /**
78
+ * Extract parent session ID from observer session key
79
+ */
80
+ extractParentSessionId(sessionKey: string): string | null;
46
81
  private parseJsonPayload;
47
82
  private extractAssistantText;
48
83
  private scoreFromSeverity;
@@ -50,3 +85,4 @@ export declare class EmpathyObserverManager {
50
85
  private normalizeConfidence;
51
86
  }
52
87
  export declare const empathyObserverManager: EmpathyObserverManager;
88
+ export declare function isEmpathyObserverSession(sessionKey: string): boolean;