dsh-plugin-coding-delegate 0.0.0-stage → 0.2.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -2
  3. package/cordis.patch.example.yml +48 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/attestation.d.ts +25 -0
  6. package/dist/attestation.js +44 -0
  7. package/dist/containment.d.ts +33 -0
  8. package/dist/containment.js +63 -0
  9. package/dist/context-quality.d.ts +79 -0
  10. package/dist/context-quality.js +181 -0
  11. package/dist/context.d.ts +39 -0
  12. package/dist/context.js +136 -0
  13. package/dist/contracts.d.ts +174 -0
  14. package/dist/contracts.js +415 -0
  15. package/dist/declaration-egress-hook.d.ts +45 -0
  16. package/dist/declaration-egress-hook.js +125 -0
  17. package/dist/declaration-egress.d.ts +37 -0
  18. package/dist/declaration-egress.js +169 -0
  19. package/dist/delegation.d.ts +339 -0
  20. package/dist/delegation.js +933 -0
  21. package/dist/emission.d.ts +70 -0
  22. package/dist/emission.js +310 -0
  23. package/dist/guard.d.ts +162 -0
  24. package/dist/guard.js +873 -0
  25. package/dist/host-events.d.ts +15 -0
  26. package/dist/host-events.js +27 -0
  27. package/dist/index.d.ts +560 -0
  28. package/dist/index.js +1398 -0
  29. package/dist/local-classifier.d.ts +52 -0
  30. package/dist/local-classifier.js +159 -0
  31. package/dist/logging.d.ts +21 -0
  32. package/dist/logging.js +90 -0
  33. package/dist/paths.d.ts +10 -0
  34. package/dist/paths.js +83 -0
  35. package/dist/profiles.d.ts +35 -0
  36. package/dist/profiles.js +95 -0
  37. package/dist/retry-context.d.ts +52 -0
  38. package/dist/retry-context.js +97 -0
  39. package/dist/roles.d.ts +129 -0
  40. package/dist/roles.js +363 -0
  41. package/dist/routing.d.ts +35 -0
  42. package/dist/routing.js +88 -0
  43. package/dist/savings-tracker.d.ts +128 -0
  44. package/dist/savings-tracker.js +340 -0
  45. package/dist/session-events.d.ts +17 -0
  46. package/dist/session-events.js +31 -0
  47. package/dist/verification.d.ts +104 -0
  48. package/dist/verification.js +560 -0
  49. package/package.json +66 -4
package/dist/index.js ADDED
@@ -0,0 +1,1398 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runInProcessFallback = exports.resolveVerificationTimeoutMs = exports.redactVerificationOutput = exports.parseTestOutput = exports.evaluateVerificationPolicy = exports.describeFailures = exports.commandProgram = exports.DEFAULT_VERIFICATION_TIMEOUT_MS = exports.DEFAULT_VERIFICATION_POLICY = exports.sha256File = exports.saveDelegatedRegistry = exports.resolveDelegatedRegistryPath = exports.resolveContractFiles = exports.recordOperatorAttestation = exports.rememberDelegated = exports.pruneDelegatedRecords = exports.parseDelegatedRegistry = exports.mergeDelegatedRecords = exports.loadDelegatedRegistry = exports.contractViolations = exports.contractFileHashes = exports.resolveContextFiles = exports.DEFAULT_CONTEXT_MAX_BYTES = exports.roleFromLineage = exports.roleForAgent = exports.resolveLeadProviders = exports.resolveAgentRole = exports.resetAgentRoles = exports.rememberAgentRole = exports.evaluateSourceEgress = exports.detectSourceEgress = exports.describeSourceRead = exports.applyArchitectConfig = exports.applyAgentRole = exports.agentLineageRole = exports.DEFAULT_SOURCE_EGRESS_MIN_LINES = exports.AGENT_ROLE_LIMIT = exports.hasCommandWriteSignal = exports.hasCommandDeleteSignal = exports.evaluateDelegatedReadPolicy = exports.evaluateCodeWriteGuard = exports.DELETE_PRIMITIVES = exports.extractAndEmitFiles = exports.evaluateUnitScope = exports.evaluateEmissionPath = exports.isPathWithin = exports.trace = exports.resolveDataDir = exports.SavingsTracker = exports.PROFILES = void 0;
37
+ exports.LocalRouter = exports.name = exports.using = exports.inject = exports.retryContextRequests = exports.parseFailureLocations = exports.RETRY_CONTEXT_WINDOW_LINES = exports.resolveVerificationRepeats = exports.resolveDelegateStatus = exports.resolveChatCompletionsUrl = exports.parseSearchReplaceBlocks = exports.extractPromptText = exports.estimateTokenCount = exports.delegateWorker = exports.applySearchReplaceBlocks = exports.MIN_SEARCH_CHARS = exports.DEFAULT_LOCAL_ENDPOINT = exports.DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER = exports.DELEGATE_WORKER_SCHEMA = exports.DELEGATE_WORKER_OPENAI_SCHEMA = exports.foldContextQuality = exports.describeContextQuality = exports.EMPTY_CONTEXT_QUALITY = exports.runSandboxVerification = void 0;
38
+ exports.scanDLP = scanDLP;
39
+ exports.requestApprovalForWrite = requestApprovalForWrite;
40
+ exports.resolveVerificationPolicy = resolveVerificationPolicy;
41
+ exports.requestApprovalForVerification = requestApprovalForVerification;
42
+ exports.requestApprovalForWorkspace = requestApprovalForWorkspace;
43
+ exports.apply = apply;
44
+ const fs = __importStar(require("fs"));
45
+ const savings_tracker_1 = require("./savings-tracker");
46
+ Object.defineProperty(exports, "SavingsTracker", { enumerable: true, get: function () { return savings_tracker_1.SavingsTracker; } });
47
+ const profiles_1 = require("./profiles");
48
+ Object.defineProperty(exports, "PROFILES", { enumerable: true, get: function () { return profiles_1.PROFILES; } });
49
+ const local_classifier_1 = require("./local-classifier");
50
+ const logging_1 = require("./logging");
51
+ const paths_1 = require("./paths");
52
+ const declaration_egress_hook_1 = require("./declaration-egress-hook");
53
+ const containment_1 = require("./containment");
54
+ const routing_1 = require("./routing");
55
+ const attestation_1 = require("./attestation");
56
+ const emission_1 = require("./emission");
57
+ const verification_1 = require("./verification");
58
+ const context_1 = require("./context");
59
+ const guard_1 = require("./guard");
60
+ const roles_1 = require("./roles");
61
+ const delegation_1 = require("./delegation");
62
+ const contracts_1 = require("./contracts");
63
+ const context_quality_1 = require("./context-quality");
64
+ const session_events_1 = require("./session-events");
65
+ const host_events_1 = require("./host-events");
66
+ var logging_2 = require("./logging");
67
+ Object.defineProperty(exports, "resolveDataDir", { enumerable: true, get: function () { return logging_2.resolveDataDir; } });
68
+ Object.defineProperty(exports, "trace", { enumerable: true, get: function () { return logging_2.trace; } });
69
+ var paths_2 = require("./paths");
70
+ Object.defineProperty(exports, "isPathWithin", { enumerable: true, get: function () { return paths_2.isPathWithin; } });
71
+ var emission_2 = require("./emission");
72
+ Object.defineProperty(exports, "evaluateEmissionPath", { enumerable: true, get: function () { return emission_2.evaluateEmissionPath; } });
73
+ Object.defineProperty(exports, "evaluateUnitScope", { enumerable: true, get: function () { return emission_2.evaluateUnitScope; } });
74
+ Object.defineProperty(exports, "extractAndEmitFiles", { enumerable: true, get: function () { return emission_2.extractAndEmitFiles; } });
75
+ var guard_2 = require("./guard");
76
+ Object.defineProperty(exports, "DELETE_PRIMITIVES", { enumerable: true, get: function () { return guard_2.DELETE_PRIMITIVES; } });
77
+ Object.defineProperty(exports, "evaluateCodeWriteGuard", { enumerable: true, get: function () { return guard_2.evaluateCodeWriteGuard; } });
78
+ Object.defineProperty(exports, "evaluateDelegatedReadPolicy", { enumerable: true, get: function () { return guard_2.evaluateDelegatedReadPolicy; } });
79
+ Object.defineProperty(exports, "hasCommandDeleteSignal", { enumerable: true, get: function () { return guard_2.hasCommandDeleteSignal; } });
80
+ Object.defineProperty(exports, "hasCommandWriteSignal", { enumerable: true, get: function () { return guard_2.hasCommandWriteSignal; } });
81
+ var roles_2 = require("./roles");
82
+ Object.defineProperty(exports, "AGENT_ROLE_LIMIT", { enumerable: true, get: function () { return roles_2.AGENT_ROLE_LIMIT; } });
83
+ Object.defineProperty(exports, "DEFAULT_SOURCE_EGRESS_MIN_LINES", { enumerable: true, get: function () { return roles_2.DEFAULT_SOURCE_EGRESS_MIN_LINES; } });
84
+ Object.defineProperty(exports, "agentLineageRole", { enumerable: true, get: function () { return roles_2.agentLineageRole; } });
85
+ Object.defineProperty(exports, "applyAgentRole", { enumerable: true, get: function () { return roles_2.applyAgentRole; } });
86
+ Object.defineProperty(exports, "applyArchitectConfig", { enumerable: true, get: function () { return roles_2.applyArchitectConfig; } });
87
+ Object.defineProperty(exports, "describeSourceRead", { enumerable: true, get: function () { return roles_2.describeSourceRead; } });
88
+ Object.defineProperty(exports, "detectSourceEgress", { enumerable: true, get: function () { return roles_2.detectSourceEgress; } });
89
+ Object.defineProperty(exports, "evaluateSourceEgress", { enumerable: true, get: function () { return roles_2.evaluateSourceEgress; } });
90
+ Object.defineProperty(exports, "rememberAgentRole", { enumerable: true, get: function () { return roles_2.rememberAgentRole; } });
91
+ Object.defineProperty(exports, "resetAgentRoles", { enumerable: true, get: function () { return roles_2.resetAgentRoles; } });
92
+ Object.defineProperty(exports, "resolveAgentRole", { enumerable: true, get: function () { return roles_2.resolveAgentRole; } });
93
+ Object.defineProperty(exports, "resolveLeadProviders", { enumerable: true, get: function () { return roles_2.resolveLeadProviders; } });
94
+ Object.defineProperty(exports, "roleForAgent", { enumerable: true, get: function () { return roles_2.roleForAgent; } });
95
+ Object.defineProperty(exports, "roleFromLineage", { enumerable: true, get: function () { return roles_2.roleFromLineage; } });
96
+ var context_2 = require("./context");
97
+ Object.defineProperty(exports, "DEFAULT_CONTEXT_MAX_BYTES", { enumerable: true, get: function () { return context_2.DEFAULT_CONTEXT_MAX_BYTES; } });
98
+ Object.defineProperty(exports, "resolveContextFiles", { enumerable: true, get: function () { return context_2.resolveContextFiles; } });
99
+ var contracts_2 = require("./contracts");
100
+ Object.defineProperty(exports, "contractFileHashes", { enumerable: true, get: function () { return contracts_2.contractFileHashes; } });
101
+ Object.defineProperty(exports, "contractViolations", { enumerable: true, get: function () { return contracts_2.contractViolations; } });
102
+ Object.defineProperty(exports, "loadDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.loadDelegatedRegistry; } });
103
+ Object.defineProperty(exports, "mergeDelegatedRecords", { enumerable: true, get: function () { return contracts_2.mergeDelegatedRecords; } });
104
+ Object.defineProperty(exports, "parseDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.parseDelegatedRegistry; } });
105
+ Object.defineProperty(exports, "pruneDelegatedRecords", { enumerable: true, get: function () { return contracts_2.pruneDelegatedRecords; } });
106
+ Object.defineProperty(exports, "rememberDelegated", { enumerable: true, get: function () { return contracts_2.rememberDelegated; } });
107
+ Object.defineProperty(exports, "recordOperatorAttestation", { enumerable: true, get: function () { return contracts_2.recordOperatorAttestation; } });
108
+ Object.defineProperty(exports, "resolveContractFiles", { enumerable: true, get: function () { return contracts_2.resolveContractFiles; } });
109
+ Object.defineProperty(exports, "resolveDelegatedRegistryPath", { enumerable: true, get: function () { return contracts_2.resolveDelegatedRegistryPath; } });
110
+ Object.defineProperty(exports, "saveDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.saveDelegatedRegistry; } });
111
+ Object.defineProperty(exports, "sha256File", { enumerable: true, get: function () { return contracts_2.sha256File; } });
112
+ var verification_2 = require("./verification");
113
+ Object.defineProperty(exports, "DEFAULT_VERIFICATION_POLICY", { enumerable: true, get: function () { return verification_2.DEFAULT_VERIFICATION_POLICY; } });
114
+ Object.defineProperty(exports, "DEFAULT_VERIFICATION_TIMEOUT_MS", { enumerable: true, get: function () { return verification_2.DEFAULT_VERIFICATION_TIMEOUT_MS; } });
115
+ Object.defineProperty(exports, "commandProgram", { enumerable: true, get: function () { return verification_2.commandProgram; } });
116
+ Object.defineProperty(exports, "describeFailures", { enumerable: true, get: function () { return verification_2.describeFailures; } });
117
+ Object.defineProperty(exports, "evaluateVerificationPolicy", { enumerable: true, get: function () { return verification_2.evaluateVerificationPolicy; } });
118
+ Object.defineProperty(exports, "parseTestOutput", { enumerable: true, get: function () { return verification_2.parseTestOutput; } });
119
+ Object.defineProperty(exports, "redactVerificationOutput", { enumerable: true, get: function () { return verification_2.redactVerificationOutput; } });
120
+ Object.defineProperty(exports, "resolveVerificationTimeoutMs", { enumerable: true, get: function () { return verification_2.resolveVerificationTimeoutMs; } });
121
+ Object.defineProperty(exports, "runInProcessFallback", { enumerable: true, get: function () { return verification_2.runInProcessFallback; } });
122
+ Object.defineProperty(exports, "runSandboxVerification", { enumerable: true, get: function () { return verification_2.runSandboxVerification; } });
123
+ var context_quality_2 = require("./context-quality");
124
+ Object.defineProperty(exports, "EMPTY_CONTEXT_QUALITY", { enumerable: true, get: function () { return context_quality_2.EMPTY_CONTEXT_QUALITY; } });
125
+ Object.defineProperty(exports, "describeContextQuality", { enumerable: true, get: function () { return context_quality_2.describeContextQuality; } });
126
+ Object.defineProperty(exports, "foldContextQuality", { enumerable: true, get: function () { return context_quality_2.foldContextQuality; } });
127
+ var delegation_2 = require("./delegation");
128
+ Object.defineProperty(exports, "DELEGATE_WORKER_OPENAI_SCHEMA", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_OPENAI_SCHEMA; } });
129
+ Object.defineProperty(exports, "DELEGATE_WORKER_SCHEMA", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_SCHEMA; } });
130
+ Object.defineProperty(exports, "DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER; } });
131
+ Object.defineProperty(exports, "DEFAULT_LOCAL_ENDPOINT", { enumerable: true, get: function () { return delegation_2.DEFAULT_LOCAL_ENDPOINT; } });
132
+ Object.defineProperty(exports, "MIN_SEARCH_CHARS", { enumerable: true, get: function () { return delegation_2.MIN_SEARCH_CHARS; } });
133
+ Object.defineProperty(exports, "applySearchReplaceBlocks", { enumerable: true, get: function () { return delegation_2.applySearchReplaceBlocks; } });
134
+ Object.defineProperty(exports, "delegateWorker", { enumerable: true, get: function () { return delegation_2.delegateWorker; } });
135
+ Object.defineProperty(exports, "estimateTokenCount", { enumerable: true, get: function () { return delegation_2.estimateTokenCount; } });
136
+ Object.defineProperty(exports, "extractPromptText", { enumerable: true, get: function () { return delegation_2.extractPromptText; } });
137
+ Object.defineProperty(exports, "parseSearchReplaceBlocks", { enumerable: true, get: function () { return delegation_2.parseSearchReplaceBlocks; } });
138
+ Object.defineProperty(exports, "resolveChatCompletionsUrl", { enumerable: true, get: function () { return delegation_2.resolveChatCompletionsUrl; } });
139
+ Object.defineProperty(exports, "resolveDelegateStatus", { enumerable: true, get: function () { return delegation_2.resolveDelegateStatus; } });
140
+ Object.defineProperty(exports, "resolveVerificationRepeats", { enumerable: true, get: function () { return delegation_2.resolveVerificationRepeats; } });
141
+ var retry_context_1 = require("./retry-context");
142
+ Object.defineProperty(exports, "RETRY_CONTEXT_WINDOW_LINES", { enumerable: true, get: function () { return retry_context_1.RETRY_CONTEXT_WINDOW_LINES; } });
143
+ Object.defineProperty(exports, "parseFailureLocations", { enumerable: true, get: function () { return retry_context_1.parseFailureLocations; } });
144
+ Object.defineProperty(exports, "retryContextRequests", { enumerable: true, get: function () { return retry_context_1.retryContextRequests; } });
145
+ exports.inject = ['tools'];
146
+ exports.using = ['tools'];
147
+ /**
148
+ * Resolve the Session Workspace root WITHOUT declaring a hard inject dependency.
149
+ *
150
+ * ctx.workspace does not exist in Cordis (reading it throws), and dsh-workspace
151
+ * exposes a REGISTRY (ctx.workspaceRegistry), not a per-Session cwd. So probe the
152
+ * known shapes in order and report WHERE the answer came from: silently falling
153
+ * back to process.cwd() writes files into the server's own directory instead of
154
+ * the Session workspace, which is far worse than failing loudly.
155
+ */
156
+ function resolveWorkspaceDir(ctx) {
157
+ for (const key of ['DSH_WORKSPACE_ROOT', 'DSH_WORKSPACE', 'WORKSPACE_ROOT']) {
158
+ const candidate = process.env[key];
159
+ if (typeof candidate === 'string' && candidate && fs.existsSync(candidate)) {
160
+ return { dir: candidate, source: `env:${key}` };
161
+ }
162
+ }
163
+ try {
164
+ for (const serviceName of ['workspace', 'workspaceRegistry', 'workspaceFiles', 'session']) {
165
+ const service = typeof ctx?.get === 'function' ? ctx.get(serviceName) : undefined;
166
+ if (!service)
167
+ continue;
168
+ for (const prop of ['dir', 'cwd', 'root', 'path', 'workspaceDir']) {
169
+ const value = service[prop];
170
+ if (typeof value === 'string' && value && fs.existsSync(value)) {
171
+ return { dir: value, source: `ctx.get('${serviceName}').${prop}` };
172
+ }
173
+ }
174
+ }
175
+ }
176
+ catch {
177
+ // service unavailable in this context - fall through to the cwd fallback
178
+ }
179
+ return { dir: process.cwd(), source: 'process.cwd() FALLBACK (not a Session workspace)' };
180
+ }
181
+ exports.name = 'dsh-plugin-coding-delegate';
182
+ // resolveDataDir and trace moved to ./logging.ts and are imported above. The re-export beside the other
183
+ // module re-exports keeps `resolveDataDir` on the public surface, where callers already depend on it.
184
+ // The delegate_worker schemas moved to ./delegation.ts and are re-exported below.
185
+ function scanDLP(text, options = {}) {
186
+ if (!text)
187
+ return { hasSensitiveData: false, violations: [], highConfidence: false };
188
+ const violations = [];
189
+ // Shared with the classifier: an enforcing gate must not use a weaker rule set
190
+ // than the classifier that reports alongside it (it previously did, which let
191
+ // `password: "..."` through to the cloud).
192
+ for (const { name, pattern } of local_classifier_1.SECRET_PATTERN_RULES) {
193
+ if (pattern.test(text)) {
194
+ violations.push(name);
195
+ }
196
+ }
197
+ // A recognised shape or keyword-assigned value is high confidence.
198
+ const highConfidence = violations.length > 0;
199
+ // The entropy backstop. Deliberately medium confidence: a high-entropy token may
200
+ // equally be a digest or a base64 payload, so it must not hard-block a session.
201
+ if (options.entropyCheck !== false) {
202
+ const tokens = (0, local_classifier_1.findHighEntropyTokens)(text, {
203
+ minBitsPerChar: options.entropyMinBitsPerChar,
204
+ minLength: options.entropyMinLength,
205
+ });
206
+ if (tokens.length > 0)
207
+ violations.push('High-entropy string');
208
+ }
209
+ return { hasSensitiveData: violations.length > 0, violations, highConfidence };
210
+ }
211
+ class LocalRouter {
212
+ config;
213
+ constructor(config = {}) {
214
+ const contextThreshold = config.contextThreshold ??
215
+ config.contextTokenThreshold ??
216
+ parseInt(process.env.CONTEXT_TOKEN_THRESHOLD || '30000', 10);
217
+ this.config = {
218
+ localProvider: config.localProvider || profiles_1.PROFILES.WORKER.provider,
219
+ cloudProvider: config.cloudProvider || profiles_1.PROFILES.ARCHITECT.provider,
220
+ localModel: config.localModel || profiles_1.PROFILES.WORKER.model,
221
+ cloudModel: config.cloudModel || profiles_1.PROFILES.ARCHITECT.model,
222
+ contextThreshold,
223
+ timeoutMs: config.timeoutMs || 2000,
224
+ enforceDLP: config.enforceDLP ?? true,
225
+ };
226
+ }
227
+ getConfig() {
228
+ return this.config;
229
+ }
230
+ async predictRoute(promptText) {
231
+ const dlpResult = scanDLP(promptText);
232
+ if (dlpResult.hasSensitiveData) {
233
+ return {
234
+ provider: this.config.localProvider,
235
+ model: this.config.localModel,
236
+ route: 'WORKER_LOCAL',
237
+ gate: 'Gate 1 (Local Classifier - DLP Firewall)',
238
+ rationale: `Sensitive credentials detected by DLP firewall (${dlpResult.violations.join(', ')}). Routing payload locally to protect privacy.`,
239
+ scores: { is_private: 0.99, complexity: 1, target: 'LOCAL_5090' },
240
+ latencyMs: 0,
241
+ dlpViolations: dlpResult.violations,
242
+ };
243
+ }
244
+ const tokens = (0, delegation_1.estimateTokenCount)(promptText);
245
+ if (tokens > this.config.contextThreshold) {
246
+ return {
247
+ provider: this.config.cloudProvider,
248
+ model: this.config.cloudModel,
249
+ route: 'ARCHITECT_CLOUD',
250
+ gate: 'Gate 0 (Guard - Token Threshold)',
251
+ rationale: `Prompt token count (${tokens}) exceeds local context threshold (${this.config.contextThreshold}). Routing directly to Cloud.`,
252
+ scores: { is_private: 0, complexity: 5, target: 'CLOUD_DEEPSEEK' },
253
+ latencyMs: 0,
254
+ };
255
+ }
256
+ // Routing is decided in-process. There is no decision daemon, no HTTP hop and
257
+ // no timeout to pay on every routing call, and nothing to fail over to: the
258
+ // classifier always returns a decision.
259
+ const decision = (0, local_classifier_1.classifyLocally)(promptText);
260
+ const isCloud = decision.route === 'cloud';
261
+ return {
262
+ provider: isCloud ? this.config.cloudProvider : this.config.localProvider,
263
+ model: isCloud ? this.config.cloudModel : this.config.localModel,
264
+ route: isCloud ? 'ARCHITECT_CLOUD' : 'WORKER_LOCAL',
265
+ gate: decision.gate,
266
+ rationale: decision.rationale,
267
+ scores: decision.scores,
268
+ latencyMs: decision.latencyMs,
269
+ };
270
+ }
271
+ async handleBeforeRequest(session) {
272
+ if (!session)
273
+ return session;
274
+ const fullText = (0, delegation_1.extractPromptText)(session);
275
+ if (!fullText || fullText.trim().length === 0) {
276
+ return {
277
+ ...session,
278
+ provider: this.config.cloudProvider,
279
+ model: this.config.cloudModel,
280
+ };
281
+ }
282
+ const decision = await this.predictRoute(fullText);
283
+ const routerMeta = {
284
+ provider: decision.provider,
285
+ model: decision.model,
286
+ route: decision.route,
287
+ gate: decision.gate,
288
+ rationale: decision.rationale,
289
+ scores: decision.scores,
290
+ latencyMs: decision.latencyMs,
291
+ dlpViolations: decision.dlpViolations,
292
+ tier: decision.route === 'ARCHITECT_CLOUD' ? 'Cloud Tier (Cloud Architect)' : 'Local Tier (Local Worker)',
293
+ estimatedTokens: (0, delegation_1.estimateTokenCount)(fullText),
294
+ };
295
+ (0, logging_1.trace)('ROUTER_DECISION', {
296
+ prompt: fullText.slice(0, 100).replace(/\n/g, ' '),
297
+ tokens: (0, delegation_1.estimateTokenCount)(fullText),
298
+ gate: decision.gate,
299
+ selectedProvider: decision.provider,
300
+ selectedModel: decision.model,
301
+ rationale: decision.rationale,
302
+ });
303
+ const isLocal = decision.provider === this.config.localProvider;
304
+ if (Object.isExtensible(session)) {
305
+ try {
306
+ session.provider = decision.provider;
307
+ session.model = decision.model;
308
+ session.apiKey = isLocal ? 'KEY' : undefined;
309
+ if (isLocal) {
310
+ delete session.reasoningEffort;
311
+ }
312
+ if (!session.options)
313
+ session.options = {};
314
+ if (Object.isExtensible(session.options)) {
315
+ session.options.provider = decision.provider;
316
+ session.options.model = decision.model;
317
+ if (isLocal) {
318
+ session.options.apiKey = 'KEY';
319
+ delete session.options.reasoningEffort;
320
+ }
321
+ }
322
+ if (!session.metadata)
323
+ session.metadata = {};
324
+ if (Object.isExtensible(session.metadata)) {
325
+ session.metadata.router = routerMeta;
326
+ }
327
+ }
328
+ catch (err) { }
329
+ }
330
+ const updatedOptions = {
331
+ ...(session.options || {}),
332
+ provider: decision.provider,
333
+ model: decision.model,
334
+ };
335
+ if (isLocal) {
336
+ updatedOptions.apiKey = 'KEY';
337
+ delete updatedOptions.reasoningEffort;
338
+ }
339
+ const updated = {
340
+ ...session,
341
+ provider: decision.provider,
342
+ model: decision.model,
343
+ options: updatedOptions,
344
+ metadata: {
345
+ ...(session.metadata || {}),
346
+ router: routerMeta,
347
+ },
348
+ };
349
+ if (isLocal) {
350
+ updated.apiKey = 'KEY';
351
+ delete updated.reasoningEffort;
352
+ }
353
+ return updated;
354
+ }
355
+ async handleError(session, error) {
356
+ if (!session)
357
+ return session;
358
+ const currentProvider = session.provider || session.options?.provider || this.config.localProvider;
359
+ if (currentProvider === this.config.localProvider || session.metadata?.router?.route === 'WORKER_LOCAL' || session.metadata?.router?.route === 'local') {
360
+ const errorMessage = error?.message || String(error);
361
+ const prevMetadata = session.metadata?.router;
362
+ const routerMeta = {
363
+ ...prevMetadata,
364
+ failover: true,
365
+ previousProvider: currentProvider,
366
+ provider: this.config.cloudProvider,
367
+ model: this.config.cloudModel,
368
+ route: 'cloud-failover',
369
+ gate: 'Gate 2 (Guard - Automatic Cloud Failover)',
370
+ rationale: `Local LM Studio provider failure caught (${errorMessage}). Transparently re-dispatching turn to DeepSeek Cloud.`,
371
+ tier: 'Cloud Tier (DeepSeek Cloud Fallback)',
372
+ estimatedTokens: prevMetadata?.estimatedTokens || 0,
373
+ };
374
+ (0, logging_1.trace)('ROUTER_FAILOVER', {
375
+ errorMessage,
376
+ cloudProvider: this.config.cloudProvider,
377
+ cloudModel: this.config.cloudModel,
378
+ });
379
+ if (Object.isExtensible(session)) {
380
+ try {
381
+ session.provider = this.config.cloudProvider;
382
+ session.model = this.config.cloudModel;
383
+ if (session.options && Object.isExtensible(session.options)) {
384
+ session.options.provider = this.config.cloudProvider;
385
+ session.options.model = this.config.cloudModel;
386
+ }
387
+ if (session.metadata && Object.isExtensible(session.metadata)) {
388
+ session.metadata.router = routerMeta;
389
+ }
390
+ }
391
+ catch (err) { }
392
+ }
393
+ const updated = {
394
+ ...session,
395
+ provider: this.config.cloudProvider,
396
+ model: this.config.cloudModel,
397
+ options: {
398
+ ...(session.options || {}),
399
+ provider: this.config.cloudProvider,
400
+ model: this.config.cloudModel,
401
+ },
402
+ metadata: {
403
+ ...(session.metadata || {}),
404
+ router: routerMeta,
405
+ },
406
+ };
407
+ if (typeof session.redispatch === 'function') {
408
+ await session.redispatch();
409
+ }
410
+ else if (typeof session.retry === 'function') {
411
+ await session.retry();
412
+ }
413
+ return updated;
414
+ }
415
+ return session;
416
+ }
417
+ }
418
+ exports.LocalRouter = LocalRouter;
419
+ /**
420
+ * The prompt stashed at `agent/pre-step` for the request hook, keyed by session AND turn.
421
+ *
422
+ * The key carries the session because turn numbers are per session: every delegated subagent starts
423
+ * at turn 1, so keying on the turn alone let two sessions sitting on the same turn hand each other
424
+ * their text. The gate scans `corpus.length > prompt.length ? corpus : prompt`, so a borrowed prompt
425
+ * longer than this session's own corpus replaced it and this session's text was never scanned at all
426
+ * -- a credential went out unscanned, measured in tests/oracles/dlp-session-scope.test.cjs.
427
+ */
428
+ const pendingTurnPrompts = new Map();
429
+ /** One stashed prompt per turn in flight; a turn whose request never runs would otherwise leak one. */
430
+ const PENDING_TURN_PROMPTS_MAX = 256;
431
+ /** Session ids and turn numbers cannot contain NUL, so the key is injective by construction. */
432
+ const TURN_PROMPT_SEPARATOR = '\u0000';
433
+ /**
434
+ * Every user-message text this plugin has seen, per session.
435
+ *
436
+ * The DLP gate can only scan what the host hands it: `agent/pre-step` receives the
437
+ * messages claimed for the current turn, and `agent/request` receives no message content
438
+ * at all because the outbound conversation is assembled from the session surface after
439
+ * that waterfall. Scanning only the newest message therefore let a credential introduced
440
+ * in an earlier turn be re-sent on every later request without tripping the gate.
441
+ * Accumulating makes the gate sticky instead.
442
+ */
443
+ const sessionPromptCorpus = new Map();
444
+ const SESSION_CORPUS_MAX_CHARS = 200_000;
445
+ const GLOBAL_CORPUS_KEY = '__global__';
446
+ function corpusKeyFor(payload) {
447
+ const id = payload?.agent?.id ?? payload?.agent?.session?.id ?? payload?.session?.id;
448
+ return typeof id === 'string' && id.length > 0 ? id : GLOBAL_CORPUS_KEY;
449
+ }
450
+ /** The stash key for one session's turn. */
451
+ function turnPromptKey(payload, turn) {
452
+ return corpusKeyFor(payload) + TURN_PROMPT_SEPARATOR + String(turn);
453
+ }
454
+ /** Stash one turn's prompt, keeping insertion order as recency and bounding the map. */
455
+ function rememberTurnPrompt(payload, turn, prompt) {
456
+ const key = turnPromptKey(payload, turn);
457
+ pendingTurnPrompts.delete(key);
458
+ pendingTurnPrompts.set(key, prompt);
459
+ while (pendingTurnPrompts.size > PENDING_TURN_PROMPTS_MAX) {
460
+ const oldest = pendingTurnPrompts.keys().next().value;
461
+ if (oldest === undefined)
462
+ break;
463
+ pendingTurnPrompts.delete(oldest);
464
+ }
465
+ }
466
+ function accumulateCorpus(key, text) {
467
+ if (!text)
468
+ return;
469
+ const target = key || GLOBAL_CORPUS_KEY;
470
+ const existing = sessionPromptCorpus.get(target) ?? '';
471
+ if (existing.includes(text))
472
+ return;
473
+ const combined = existing ? `${existing}\n${text}` : text;
474
+ sessionPromptCorpus.set(target, combined.length > SESSION_CORPUS_MAX_CHARS
475
+ ? combined.slice(combined.length - SESSION_CORPUS_MAX_CHARS)
476
+ : combined);
477
+ }
478
+ function extractTextFromClaimedMessages(messages) {
479
+ if (!Array.isArray(messages))
480
+ return '';
481
+ for (let i = messages.length - 1; i >= 0; i--) {
482
+ const m = messages[i];
483
+ if (!m)
484
+ continue;
485
+ if (typeof m.text === 'string' && m.text.trim())
486
+ return m.text.trim();
487
+ if (typeof m.prompt === 'string' && m.prompt.trim())
488
+ return m.prompt.trim();
489
+ if (typeof m.content === 'string' && m.content.trim())
490
+ return m.content.trim();
491
+ if (Array.isArray(m.content)) {
492
+ for (const block of m.content) {
493
+ if (block?.type === 'text' && typeof block?.text === 'string' && block.text.trim()) {
494
+ if (!block.text.startsWith('Current runtime context.')) {
495
+ return block.text.trim();
496
+ }
497
+ }
498
+ }
499
+ }
500
+ }
501
+ return '';
502
+ }
503
+ let isPluginApplied = false;
504
+ /**
505
+ * Print the measured reference throughput for the local worker models, so the
506
+ * server console carries a baseline next to the live per-call LEDGER_AUDIT rate.
507
+ */
508
+ function logWorkerBenchmarks() {
509
+ console.log(`[WORKER_BENCH] Reference throughput (${profiles_1.WORKER_BENCHMARK_SOURCE})`);
510
+ console.log('[WORKER_BENCH] Local work is unmetered, so the metered plan is spent on thinking rather than on reading code.');
511
+ console.log('[WORKER_BENCH] LEDGER_AUDIT reports CloudEquiv: what the local tokens would have cost at the metered tier.');
512
+ console.log('[WORKER_BENCH] That is the plan exposure you avoided, not money saved -- the GPU is a fixed cost.');
513
+ console.log('[WORKER_BENCH] LEDGER_AUDIT carries running totals: Metered is what the plan paid, Local is what the GPU did.');
514
+ console.log('[WORKER_BENCH] model decode ttft note');
515
+ for (const bench of profiles_1.WORKER_BENCHMARKS) {
516
+ const active = bench.model === profiles_1.PROFILES.WORKER.model ? ' <- active worker' : '';
517
+ console.log(`[WORKER_BENCH] ${bench.model.padEnd(24)} ${`${bench.decodeTps.toFixed(1)} tok/s`.padEnd(13)} ${bench.ttft.padEnd(10)} ${bench.note}${active}`);
518
+ }
519
+ console.log(`[WORKER_BENCH] Active worker model: ${profiles_1.PROFILES.WORKER.model} (max_tokens: ${profiles_1.PROFILES.WORKER.max_tokens ?? 'unset'}, thinking: ${profiles_1.PROFILES.WORKER.enable_thinking === false ? 'off' : 'on'})`);
520
+ }
521
+ const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
522
+ /**
523
+ * Route an `ask` guard decision through the real approval seam.
524
+ *
525
+ * Returning `{ kind: 'ask' }` from a tools/pre-execute listener prompts nobody: the
526
+ * pipeline understands only `deny`, so every other kind is an allow. The seam that
527
+ * actually asks is `dsh-user-approval` (ctx.approval), and it fails closed — a
528
+ * missing service, a missing agent, an idle turn or a throwing answerer all resolve
529
+ * to 'unavailable', which this treats as a refusal.
530
+ */
531
+ async function requestApprovalForWrite(ctx, exec, verdict) {
532
+ try {
533
+ const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
534
+ if (!service || typeof service.request !== 'function')
535
+ return 'unavailable';
536
+ if (!exec?.agent)
537
+ return 'unavailable';
538
+ const outcome = await service.request({
539
+ agent: exec.agent,
540
+ toolName: String(exec?.name || 'unknown'),
541
+ ...(exec?.callId ? { callId: exec.callId } : {}),
542
+ reason: verdict.reason,
543
+ ...(exec?.signal ? { signal: exec.signal } : {}),
544
+ });
545
+ // Validate against the vocabulary rather than trusting the answerer: the service
546
+ // normalises rogue values, but a bridge that assumes it would treat any unknown
547
+ // string as a decision. Anything unrecognised fails closed.
548
+ return typeof outcome === 'string' && APPROVAL_OUTCOMES.has(outcome)
549
+ ? outcome
550
+ : 'unavailable';
551
+ }
552
+ catch (err) {
553
+ // No open turn, suspended session, or a broken answerer: fail closed.
554
+ console.warn('[LOCAL_GUARD] approval request failed; failing closed:', err?.message || err);
555
+ return 'unavailable';
556
+ }
557
+ }
558
+ function resolveVerificationPolicy(options = {}) {
559
+ // A blank or non-string coherence command is absent, not an empty command: an empty string would run
560
+ // as a no-op subprocess and report success, which is a project check that checks nothing.
561
+ const coherence = typeof options.coherenceVerification === 'string' && options.coherenceVerification.trim()
562
+ ? options.coherenceVerification.trim()
563
+ : undefined;
564
+ return {
565
+ mode: options.verificationApproval ?? 'ask',
566
+ allowlist: Array.isArray(options.verificationAllowlist) ? options.verificationAllowlist : [],
567
+ allowInProcessFallback: options.allowInProcessFallback === true,
568
+ timeoutMs: (0, verification_1.resolveVerificationTimeoutMs)(options.verificationTimeoutMs),
569
+ ...(coherence ? { coherenceVerification: coherence } : {}),
570
+ };
571
+ }
572
+ /**
573
+ * Ask the operator to approve one model-selected verification command. Unlike the write
574
+ * guard this is not a `tools/pre-execute` decision, because the command runs after the
575
+ * worker responds; it is asked before dispatch so the operator sees it up front.
576
+ * Fails closed on every error path.
577
+ */
578
+ async function requestApprovalForVerification(ctx, exec, command) {
579
+ try {
580
+ const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
581
+ if (!service || typeof service.request !== 'function')
582
+ return false;
583
+ if (!exec?.agent)
584
+ return false;
585
+ const outcome = await service.request({
586
+ agent: exec.agent,
587
+ toolName: 'delegate_worker',
588
+ ...(exec?.callId ? { callId: exec.callId } : {}),
589
+ reason: `delegate_worker wants to run this verification command with the full authority of the DSH ` +
590
+ `process:\n ${command}\n` +
591
+ `It is model-selected and is not confined to the workspace. Approve it only if you recognise it.`,
592
+ ...(exec?.signal ? { signal: exec.signal } : {}),
593
+ });
594
+ return outcome === 'allowed-once';
595
+ }
596
+ catch (err) {
597
+ console.warn('[LOCAL_GUARD] verification approval request failed; failing closed:', err?.message || err);
598
+ return false;
599
+ }
600
+ }
601
+ /**
602
+ * Ask the operator before a delegation writes into a directory that is neither the session workspace
603
+ * nor an allowlisted root. Same seam as the verification prompt above, and the same fail-closed rule:
604
+ * no approval service, no agent, or a thrown request all resolve to refusal rather than to consent.
605
+ */
606
+ async function requestApprovalForWorkspace(ctx, exec, dir, reason) {
607
+ try {
608
+ const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
609
+ if (!service || typeof service.request !== 'function')
610
+ return false;
611
+ if (!exec?.agent)
612
+ return false;
613
+ const outcome = await service.request({
614
+ agent: exec.agent,
615
+ toolName: 'delegate_worker',
616
+ ...(exec?.callId ? { callId: exec.callId } : {}),
617
+ reason: 'delegate_worker wants to write into a directory outside the session workspace:\n ' +
618
+ dir +
619
+ '\n' +
620
+ reason +
621
+ '\nApprove only if you intend this delegation to write there.',
622
+ ...(exec?.signal ? { signal: exec.signal } : {}),
623
+ });
624
+ return outcome === 'allowed-once';
625
+ }
626
+ catch (err) {
627
+ console.warn('[LOCAL_GUARD] workspace approval request failed; failing closed:', err?.message || err);
628
+ return false;
629
+ }
630
+ }
631
+ // Role resolution, the architect config and the lead tier moved to ./roles.ts and are imported
632
+ // above. They are re-exported beside the other module re-exports because callers depend on them.
633
+ function apply(ctx, options = {}) {
634
+ // Restore what was delegated before this process started. Without this, a restart silently widened
635
+ // what the architect may read back — the gap a live run found, and the reason this is not merely
636
+ // in-memory state any more.
637
+ for (const record of (0, contracts_1.loadDelegatedRegistry)()) {
638
+ if (record.mode === 'created')
639
+ contracts_1.delegatedPaths.add((0, paths_1.canonicalisePath)(record.path));
640
+ }
641
+ const REGISTERED_KEY = Symbol.for('dsh-plugin-coding-delegate.registered');
642
+ const isTest = process.env.NODE_ENV === 'test';
643
+ if (!isTest) {
644
+ if (isPluginApplied || ctx[REGISTERED_KEY] || globalThis[REGISTERED_KEY]) {
645
+ console.warn('[LOCAL_ROUTER] Plugin already registered. Skipping duplicate mounting.');
646
+ return;
647
+ }
648
+ isPluginApplied = true;
649
+ ctx[REGISTERED_KEY] = true;
650
+ globalThis[REGISTERED_KEY] = true;
651
+ }
652
+ console.log('[LOCAL_ROUTER_DEBUG] ctx.tools available:', Boolean(ctx.tools));
653
+ const router = new LocalRouter(options);
654
+ const config = router.getConfig();
655
+ const tracker = new savings_tracker_1.SavingsTracker((0, logging_1.resolveDataDir)());
656
+ (0, logging_1.trace)('PLUGIN_INIT_ASYMMETRIC_ORCHESTRATOR', { config });
657
+ // Register `delegate_worker` strictly adhering to `@deepseek-ai/dsh-tools` and DeepSeek JSON Schema contract
658
+ if (ctx.tools && typeof ctx.tools.register === 'function') {
659
+ try {
660
+ const dshToolDef = {
661
+ name: 'delegate_worker',
662
+ description: 'Dispatches a discrete implementation, testing, or code-generation task to the configured local execution worker -- any OpenAI-compatible server (LM Studio, Ollama, vLLM, llama.cpp) -- with an isolated context window. The worker has no repository read: declare contextFiles for the code it must see, since it cannot discover anything itself.',
663
+ // SINGLE SOURCE OF TRUTH. These parameters were previously duplicated here and in
664
+ // DELEGATE_WORKER_OPENAI_SCHEMA in delegation.ts, and the duplication already cost a false test:
665
+ // adding attestTargets/attestEvidence to this copy alone left the tool listing unchanged and the
666
+ // delegated call was silently stripped of both -- the tool reported success while the parameters
667
+ // never arrived. delegation.ts owns the list; this file references it.
668
+ parameters: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA.function.parameters,
669
+ output: {
670
+ schema: {
671
+ type: 'object',
672
+ additionalProperties: true,
673
+ },
674
+ render: (_args, value) => [
675
+ {
676
+ type: 'text',
677
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
678
+ },
679
+ ],
680
+ },
681
+ async execute(args, exec) {
682
+ const resolved = resolveWorkspaceDir(ctx);
683
+ const explicitDir = args?.workspaceDir;
684
+ // `endpoint` is not a declared tool argument and is deliberately dropped:
685
+ // delegateWorker would otherwise POST the task to whatever URL a caller named.
686
+ const { endpoint: _ignoredEndpoint, ...callerArgs } = args || {};
687
+ // Attestation is a SEPARATE act from delegation and must not be smuggled into it. A caller may
688
+ // name paths to attest here so the operator's own review can be recorded through a tool call
689
+ // rather than a shell one-liner -- the manual route was the only one available, which is exactly
690
+ // why the promotion socket sat patched-but-unattested for so long.
691
+ const { attestTargets, attestEvidence, attestOperator, ...delegationArgs } = callerArgs;
692
+ const policy = resolveVerificationPolicy(options);
693
+ // The workspaceDir ARGUMENT chooses the base directory for every containment check, and it is
694
+ // model-visible. Containment of the emitted files was always sound; what was missing is that
695
+ // the base itself was supplied by the caller being contained, so naming a directory outside
696
+ // the session workspace made every later boundary true by construction.
697
+ const containment = (0, containment_1.resolveWorkspaceContainment)({
698
+ requested: explicitDir,
699
+ sessionRoot: resolved.dir,
700
+ allowedRoots: options?.emitAllowlist,
701
+ canPrompt: typeof ctx?.get === 'function' && Boolean(ctx.get('approval')),
702
+ });
703
+ if (!containment.trusted && !containment.needsApproval) {
704
+ return {
705
+ success: false,
706
+ status: 'CONTEXT_REFUSED',
707
+ taskName: String(args?.taskName || ''),
708
+ message: String(containment.reason),
709
+ error: 'workspaceDir refused',
710
+ resolvedWorkspace: resolved.dir,
711
+ filesWritten: [],
712
+ testResults: { passed: 0, failed: 0, output: 'The worker was not called.' },
713
+ tokens: { prompt: 0, completion: 0 },
714
+ };
715
+ }
716
+ if (!containment.trusted && containment.needsApproval) {
717
+ const approved = await requestApprovalForWorkspace(ctx, exec, String(containment.dir), String(containment.reason));
718
+ if (!approved) {
719
+ return {
720
+ success: false,
721
+ status: 'CONTEXT_REFUSED',
722
+ taskName: String(args?.taskName || ''),
723
+ message: String(containment.reason),
724
+ error: 'workspaceDir refused: operator approval was not granted',
725
+ resolvedWorkspace: resolved.dir,
726
+ filesWritten: [],
727
+ testResults: { passed: 0, failed: 0, output: 'The worker was not called.' },
728
+ tokens: { prompt: 0, completion: 0 },
729
+ };
730
+ }
731
+ }
732
+ const verdict = await (0, delegation_1.delegateWorker)({
733
+ ...delegationArgs,
734
+ // Operator settings, not caller arguments. The local endpoint and model are
735
+ // trusted configuration; a caller-supplied `endpoint` was dropped just above.
736
+ ...(options?.localEndpoint ? { endpoint: options.localEndpoint } : {}),
737
+ ...(options?.localModel ? { model: options.localModel } : {}),
738
+ workspaceDir: explicitDir || resolved.dir,
739
+ workspaceSource: explicitDir ? 'caller-supplied workspaceDir' : resolved.source,
740
+ verificationPolicy: policy,
741
+ emitAllowlist: options?.emitAllowlist,
742
+ // Operator setting, placed after the caller args for the same reason as `unitScope` below:
743
+ // a caller must not be able to switch this off, and an operator who has switched it off must
744
+ // not have it switched back on by a caller.
745
+ retryContext: options?.retryContext ?? 'auto',
746
+ verificationApproval: (command) => requestApprovalForVerification(ctx, exec, command),
747
+ }, tracker);
748
+ if (Array.isArray(attestTargets) && attestTargets.length > 0) {
749
+ // Only a PASSING unit may be attested from here. An attestation is a human claim about content;
750
+ // attaching one to a unit that failed or was never verified would launder a machine outcome
751
+ // into a human statement, which is the one thing this verdict must not become.
752
+ //
753
+ // The gate is `status === 'SUCCESS'`, NOT 'UNIT_PASSED'. The first version of this compared
754
+ // against 'UNIT_PASSED' and refused every attestation, because `resolveDelegateStatus` returns
755
+ // 'SUCCESS' / 'INCOHERENT' / 'VERIFICATION_FAILED' / 'UNVERIFIED' / 'FLAKY' and never emits the
756
+ // registry's outcome vocabulary at all. The two vocabularies are genuinely different, and the
757
+ // mistake was silent in the worst way: the tool reported success while quietly skipping the
758
+ // attestation, and the skip message blamed the unit rather than the comparison.
759
+ //
760
+ // `SUCCESS` is also the stricter of the two candidate gates, and correctly so: it requires the
761
+ // project coherence check to have passed as well, and attesting a unit whose tree is broken
762
+ // would certify content in a state nothing verified.
763
+ if (verdict && verdict.status === 'SUCCESS') {
764
+ // OPERATOR IDENTITY, and the first version of this got it exactly wrong. It fell back to
765
+ // `exec.agent.id`, which stamped an ephemeral session UUID into the audit ledger as though a
766
+ // human had signed off -- inverting the separation of duties the verdict exists to record,
767
+ // and producing a record LESS attributable than a typed name rather than more.
768
+ //
769
+ // There is deliberately NO fallback to the agent. An autonomous agent id cannot attest human
770
+ // review, so the absence of an identity is a refusal, not a value to invent. Precedence is
771
+ // the call's `attestOperator`, then the plugin's `operatorIdentity` -- explicit over ambient.
772
+ const operator = String(attestOperator || options?.operatorIdentity || '').trim();
773
+ // Scope before attesting: a path may be attested only if this unit actually wrote it.
774
+ // Attesting anything else is how a caller forged a human review over a file the unit
775
+ // never touched. Placed after `operator` because the helper needs it.
776
+ const scope = (0, attestation_1.selectAttestableTargets)({
777
+ requested: Array.isArray(attestTargets) ? attestTargets : [],
778
+ filesWritten: Array.isArray(verdict?.filesWritten) ? verdict.filesWritten : [],
779
+ operator,
780
+ });
781
+ if (scope.refused.length > 0) {
782
+ verdict.attestationErrors = scope.refused.map((p) => 'attestTargets must be written and verified in the current unit: ' + p);
783
+ }
784
+ if (!operator) {
785
+ // Refused, and the unit's own verdict is left INTACT. Throwing here would destroy a passing
786
+ // unit's result over a bookkeeping failure, which is not what failing closed means at this
787
+ // seam: the delegation succeeded, and only the attestation did not.
788
+ verdict.attestationSkipped =
789
+ 'attestTargets was provided but no operator identity is available, so nothing was attested. ' +
790
+ "Pass attestOperator on the call, or set 'operatorIdentity' in the plugin configuration. " +
791
+ 'An autonomous agent id cannot attest human review, so it is never used as a fallback.';
792
+ console.warn('[LOCAL_EGRESS] attestation refused: no operator identity (attestOperator or operatorIdentity)');
793
+ }
794
+ else {
795
+ const evidence = String(attestEvidence || '').trim() ||
796
+ 'operator reviewed the passing unit from delegation ' + String(verdict.taskName || '');
797
+ const attested = [];
798
+ // Only what this unit wrote, so an answer-only delegation attests nothing.
799
+ for (const target of scope.attestable) {
800
+ try {
801
+ const record = (0, contracts_1.recordOperatorAttestation)(String(target), operator, evidence);
802
+ if (record)
803
+ attested.push(record.path);
804
+ }
805
+ catch (err) {
806
+ // The recorder throws when the registry cannot be written, deliberately: an unpersisted
807
+ // attestation must not be reported as one. Surfacing it here keeps that contract.
808
+ console.warn('[LOCAL_EGRESS] attestation failed for', target, err?.message || err);
809
+ }
810
+ }
811
+ verdict.attested = attested;
812
+ if (attested.length > 0) {
813
+ verdict.summary = String(verdict.summary || '') + '\nAttested: ' + attested.join(', ') + '.';
814
+ }
815
+ }
816
+ }
817
+ else {
818
+ verdict.attestationSkipped =
819
+ 'attestTargets was provided but the unit status was ' +
820
+ String(verdict && verdict.status) +
821
+ ', not SUCCESS, so nothing was attested';
822
+ }
823
+ }
824
+ return verdict;
825
+ },
826
+ };
827
+ // DRIFT ASSERTION for the duplicated tool schema, and it exists because the duplication already cost
828
+ // a false test. The parameters are declared twice: once inline here (the dev contract this file
829
+ // registers) and once in `DELEGATE_WORKER_OPENAI_SCHEMA` in delegation.ts, which is what the host
830
+ // receives. Adding `attestTargets`/`attestEvidence` to the inline copy alone left the tool listing
831
+ // unchanged, and the delegated call was SILENTLY stripped of both -- the tool reported success while
832
+ // the parameters never arrived.
833
+ //
834
+ // `delegation.ts` owns the canonical list. This asserts the local copy still agrees with it, so the
835
+ // next parameter added to one and not the other fails loudly at registration instead of quietly
836
+ // dropping an argument. Sorted key comparison plus the `required` list, because a missing parameter
837
+ // and a reordered one are not the same defect and only the first is worth waking anyone for.
838
+ {
839
+ const canonical = delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA?.function?.parameters;
840
+ const inlineParams = dshToolDef?.parameters;
841
+ if (canonical && canonical.properties && inlineParams && inlineParams.properties) {
842
+ const a = Object.keys(canonical.properties).sort().join(',');
843
+ const b = Object.keys(inlineParams.properties).sort().join(',');
844
+ const reqA = JSON.stringify(canonical.required || []);
845
+ const reqB = JSON.stringify(inlineParams.required || []);
846
+ if (a !== b || reqA !== reqB) {
847
+ throw new Error('delegate_worker schema drift: the inline parameters in index.ts disagree with ' +
848
+ 'DELEGATE_WORKER_OPENAI_SCHEMA in delegation.ts. ' +
849
+ 'Canonical: [' +
850
+ a +
851
+ '] required ' +
852
+ reqA +
853
+ '. Inline: [' +
854
+ b +
855
+ '] required ' +
856
+ reqB +
857
+ '. Add the parameter to BOTH or neither -- a parameter present in only one is silently ' +
858
+ 'dropped from the tool call, which is how attestTargets was lost the first time.');
859
+ }
860
+ }
861
+ }
862
+ try {
863
+ ;
864
+ ctx.tools.register(dshToolDef);
865
+ }
866
+ catch (e) {
867
+ ;
868
+ ctx.tools.register('delegate_worker', dshToolDef, dshToolDef.execute);
869
+ }
870
+ console.log("[LOCAL_ROUTER_INIT] Tool 'delegate_worker' registered successfully on ctx.tools.");
871
+ logWorkerBenchmarks();
872
+ }
873
+ catch (e) {
874
+ console.warn("[LOCAL_ROUTER_INIT] Failed to register tool via ctx.tools.register:", e?.message || String(e));
875
+ }
876
+ }
877
+ else {
878
+ // There is NO fallback. The listener that used to sit here subscribed to `ctx.on('tool/call')`, which
879
+ // is not a Cordis hook at all: `tool/call` is a SESSION EVENT type, delivered through `session/event`,
880
+ // and an observer of the session log cannot answer a tool invocation in any case. So the fallback
881
+ // could never have run, and if `ctx.tools.register` ever fails this plugin simply has no tool.
882
+ console.warn('[LOCAL_ROUTER_INIT] ctx.tools is unavailable. There is no fallback path: this plugin can only ' +
883
+ 'serve delegate_worker through ctx.tools.register.');
884
+ }
885
+ // Local-only code guard: refuse cloud-authored source writes so that all code
886
+ // work routes through delegate_worker to the local worker.
887
+ if (options?.localCodeGuard !== false) {
888
+ // Parameters inferred from the host's `tools/pre-execute` signature: `(exec: ToolExecution, next)`.
889
+ (0, host_events_1.onHost)(ctx, 'tools/pre-execute', async (exec, next) => {
890
+ // `as const` keeps `kind` the literal the host's `PreToolDecision` requires. Without it the object
891
+ // widens to `{ kind: string }` and the whole handler stops satisfying the host's signature -- which
892
+ // is what made this one line worth checking rather than assuming.
893
+ const decision = typeof next === 'function' ? await next() : { kind: 'allow' };
894
+ if (!decision || decision.kind !== 'allow')
895
+ return decision;
896
+ try {
897
+ const verdict = (0, guard_1.evaluateCodeWriteGuard)(exec, {
898
+ askPaths: options?.guardAskPaths,
899
+ delegatedPaths: contracts_1.delegatedPaths,
900
+ delegateReadPolicy: options?.delegateReadPolicy,
901
+ contractPaths: options?.contractPaths,
902
+ contractWriteMode: options?.contractWriteMode,
903
+ });
904
+ if (verdict) {
905
+ // guardMode 'deny' wins outright: an operator who said "never prompt" must
906
+ // not be prompted just because this path would otherwise be ask-eligible.
907
+ const wantsAsk = options?.guardMode !== 'deny' && (options?.guardMode === 'ask' || verdict.kind === 'ask');
908
+ if (!wantsAsk) {
909
+ console.log(`[LOCAL_GUARD] DENY ${exec?.name} -> ${verdict.target}`);
910
+ return { kind: 'deny', reason: verdict.reason };
911
+ }
912
+ // Ask for real, through the approval seam. Only 'allowed-once' grants.
913
+ const outcome = await requestApprovalForWrite(ctx, exec, verdict);
914
+ if (outcome === 'allowed-once') {
915
+ console.log(`[LOCAL_GUARD] ALLOWED-ONCE ${exec?.name} -> ${verdict.target}`);
916
+ return decision;
917
+ }
918
+ console.log(`[LOCAL_GUARD] DENY (${outcome}) ${exec?.name} -> ${verdict.target}`);
919
+ return {
920
+ kind: 'deny',
921
+ reason: `${verdict.reason} (approval outcome: ${outcome})`,
922
+ };
923
+ }
924
+ // Observation, not enforcement: the architect is allowed to read source, and this records it.
925
+ // The guard above answers "may this happen"; this answers "did it, and who by" — which is what
926
+ // will later say whether that access is used at all, and so whether it can be taken away.
927
+ const observed = (0, roles_1.describeSourceRead)({
928
+ tool: exec?.name,
929
+ target: (0, guard_1.extractWriteTarget)(exec?.arguments),
930
+ // The live agent is passed as well as its id, so the guard can read the host's session lineage
931
+ // instead of relying only on the observed correlation. That is the whole of item 1: before
932
+ // `Session.header` existed there was nothing to read, and the plugin said so.
933
+ role: (0, roles_1.roleForAgent)(exec?.agent?.id, exec?.agent),
934
+ });
935
+ if (observed.track) {
936
+ (0, logging_1.trace)('SOURCE_READ', {
937
+ role: observed.role,
938
+ agent: String(exec?.agent?.id ?? '').slice(0, 8) || 'unknown',
939
+ tool: String(exec?.name ?? ''),
940
+ target: observed.target,
941
+ extension: observed.extension,
942
+ });
943
+ }
944
+ }
945
+ catch (err) {
946
+ // Fail closed: a guard that cannot evaluate must not wave the call through.
947
+ console.warn('[LOCAL_GUARD] evaluation failed; failing closed:', err);
948
+ return {
949
+ kind: 'deny',
950
+ reason: `Local-code guard could not evaluate this call: ${err?.message || err}`,
951
+ };
952
+ }
953
+ return decision;
954
+ });
955
+ console.log('[LOCAL_ROUTER_INIT] Local-code guard registered on tools/pre-execute.');
956
+ }
957
+ // Egress control for READS: serve the type skeleton instead of the implementation.
958
+ //
959
+ // The write guard answers "may source be authored". This answers "may implementation BODIES leave the
960
+ // machine", which is a different question and was previously unanswered -- the architect could read any
961
+ // file it liked and nothing constrained it. It cannot be done at `tools/pre-execute`, because that
962
+ // decision type is only allow/deny/ask and rewriting arguments is excluded by the host's own comment on
963
+ // `PreToolDecision`. `tools/post-execute` can replace the model-facing content, which is exactly "serve
964
+ // the declaration instead", so that is where it lives.
965
+ if (options?.sourceReadEgress === 'declarations') {
966
+ const declarationRoot = options?.declarationRoot;
967
+ if (!declarationRoot || !String(declarationRoot).trim()) {
968
+ console.warn('[LOCAL_EGRESS] sourceReadEgress is "declarations" but no declarationRoot was configured, so reads are ' +
969
+ 'being SERVED AS SOURCE. Set declarationRoot, or this setting does nothing.');
970
+ }
971
+ else {
972
+ (0, host_events_1.onHost)(ctx, 'tools/post-execute', async (exec, result, next) => {
973
+ const decision = typeof next === 'function' ? await next() : { kind: 'accept' };
974
+ if (!decision || decision.kind !== 'accept')
975
+ return decision;
976
+ try {
977
+ const name = String(exec?.name || '');
978
+ const egressDecision = (0, declaration_egress_hook_1.handleDeclarationPostExecute)(exec, options);
979
+ if (egressDecision)
980
+ return egressDecision;
981
+ if (!guard_1.READ_TOOLS.has(name))
982
+ return decision;
983
+ if (result?.isError)
984
+ return decision;
985
+ const target = (0, guard_1.extractWriteTarget)(exec?.arguments);
986
+ if (!target)
987
+ return decision;
988
+ const skeleton = (0, guard_1.declarationPathFor)(target, String(declarationRoot));
989
+ // Not a source file: nothing to strip, so the read stands.
990
+ if (!skeleton)
991
+ return decision;
992
+ // STALENESS. Until this check existed, the egress control trusted the build: it verified that a
993
+ // declaration EXISTED at the derived path, never that the declaration described the source being
994
+ // read. Edit an interface, skip the rebuild, and the architect would be served yesterday's
995
+ // signature while believing it was current -- green for the same reason every false green here has
996
+ // been green, because nothing checked the thing that mattered.
997
+ //
998
+ // Read the predicate rather than the summary: mtime is evidence of a rebuild, not proof of a
999
+ // match, and `staleDeclarationReason` says so at length.
1000
+ const stale = (0, guard_1.staleDeclarationReason)(target, skeleton);
1001
+ if (stale) {
1002
+ return {
1003
+ kind: 'block',
1004
+ feedback: [
1005
+ {
1006
+ type: 'text',
1007
+ text: "Reading '" +
1008
+ target +
1009
+ "' was refused: " +
1010
+ stale +
1011
+ '. Rebuild the declarations (tsc --declaration --emitDeclarationOnly) and read again. ' +
1012
+ 'Serving a stale declaration would let this context plan against a contract the code no ' +
1013
+ 'longer implements.',
1014
+ },
1015
+ ],
1016
+ };
1017
+ }
1018
+ let content;
1019
+ try {
1020
+ content = fs.readFileSync(skeleton, 'utf8');
1021
+ }
1022
+ catch {
1023
+ // Fail closed. A file with no emitted declaration is refused rather than served as source,
1024
+ // because serving it would be the exact leak this control exists to prevent.
1025
+ return {
1026
+ kind: 'block',
1027
+ feedback: [
1028
+ {
1029
+ type: 'text',
1030
+ text: "Reading '" +
1031
+ target +
1032
+ "' was refused: sourceReadEgress is 'declarations' and no type skeleton exists at '" +
1033
+ skeleton +
1034
+ ". " +
1035
+ // A TEST FILE HAS NO DECLARATION BY CONSTRUCTION. tsconfig.json sets rootDir ./src and
1036
+ // include [src/**/*], so a file under tests/ is outside the emit root and NO build will ever
1037
+ // produce its .d.ts. The original message told the reader to rebuild and retry, which is
1038
+ // unachievable for exactly the files most likely to be refused -- a remediation loop with no
1039
+ // exit. Separators are doubled below because this is a regex literal.
1040
+ (/(?:^|[\\/])tests?(?:[\\/]|$)/i.test(target)
1041
+ ? 'Test files live outside the declaration root and emit no skeleton, so no build will produce one. Inspect it in a local editor, or turn sourceReadEgress off in the profile patch (which needs a DSH restart).'
1042
+ : 'Build the declarations with npm run build, or read the declaration path directly.') +
1043
+ ' Implementation bodies are not served to this context.',
1044
+ },
1045
+ ],
1046
+ };
1047
+ }
1048
+ (0, logging_1.trace)('SOURCE_DECLARATION_SERVED', {
1049
+ tool: name,
1050
+ requested: String(exec?.arguments?.file_path || exec?.arguments?.path || target),
1051
+ served: skeleton,
1052
+ bytes: Buffer.byteLength(content, 'utf8'),
1053
+ });
1054
+ return {
1055
+ kind: 'accept',
1056
+ content: [
1057
+ {
1058
+ type: 'text',
1059
+ text: '// SERVED AS TYPE DECLARATIONS, NOT SOURCE. Implementation bodies are withheld by policy ' +
1060
+ '(sourceEgress: declarations).\n// Declaration file: ' +
1061
+ skeleton +
1062
+ '\n\n' +
1063
+ content,
1064
+ },
1065
+ ],
1066
+ };
1067
+ }
1068
+ catch (err) {
1069
+ // Fail closed for the same reason the write guard does: an egress filter that cannot evaluate
1070
+ // must not wave the read through.
1071
+ console.warn('[LOCAL_EGRESS] evaluation failed; refusing the read:', err);
1072
+ return {
1073
+ kind: 'block',
1074
+ feedback: [
1075
+ { type: 'text', text: 'Read refused: the source-egress filter could not evaluate this call.' },
1076
+ ],
1077
+ };
1078
+ }
1079
+ });
1080
+ console.log('[LOCAL_ROUTER_INIT] Source egress control registered on tools/post-execute.');
1081
+ }
1082
+ }
1083
+ // 1. Lightweight agent/pre-step prompt capture & DLP scanner ONLY
1084
+ (0, host_events_1.onHost)(ctx, 'agent/pre-step', async (payload, next) => {
1085
+ const turn = payload?.turn;
1086
+ const prompt = extractTextFromClaimedMessages(payload?.messages);
1087
+ if (turn !== undefined && prompt) {
1088
+ rememberTurnPrompt(payload, turn, prompt);
1089
+ accumulateCorpus(corpusKeyFor(payload), prompt);
1090
+ (0, logging_1.trace)('HOOK_CAPTURE: PROMPT_CAPTURED (agent/pre-step)', {
1091
+ turn,
1092
+ prompt: prompt.slice(0, 100),
1093
+ });
1094
+ }
1095
+ // The host types `next` as always a function, so the `payload` branch is a runtime guard the types
1096
+ // cannot see through -- and `payload` is not a `PreStepDecision`, so it never was a valid answer.
1097
+ // Asserted rather than deleted: if the guard ever fires, passing the payload through is still the
1098
+ // least surprising thing to do, and this line now says out loud that it is unverified.
1099
+ return typeof next === 'function' ? await next() : payload;
1100
+ }, { prepend: true });
1101
+ // Context-quality counters: the measurement that turns "your GPU does the typing" into an observation
1102
+ // rather than an argument.
1103
+ //
1104
+ // DSH publishes every session event on a `session/event` firehose, and its own session service
1105
+ // documents observation as "a plugin concern (subscribe to `session/event`)". So this needs no injected
1106
+ // service, which is what keeps the plugin's `inject` list -- and the oracle that asserts it -- unchanged.
1107
+ //
1108
+ // Counted per session, traced on the events that move the figures. The reclaimed-token number comes
1109
+ // from the host (`shadowedTokenCount` on `compaction/summary` and `compaction/prune`), so it is
1110
+ // reported rather than estimated.
1111
+ //
1112
+ // One limit worth knowing while reading the trace: events that entered through construction (replay,
1113
+ // fork, resume) are not published on this firehose, so a resumed session is counted from the resume
1114
+ // rather than from its true beginning. Recorded in `docs/findings.md`.
1115
+ const contextQuality = new Map();
1116
+ const qualityKeyFor = (session) => {
1117
+ const id = session?.id ?? session?.header?.id;
1118
+ return typeof id === 'string' && id.length > 0 ? id : '__global__';
1119
+ };
1120
+ // Parameters inferred from the host's `session/event` signature: `(session: Session, event: SessionEvent)`.
1121
+ (0, host_events_1.onHost)(ctx, 'session/event', (session, event) => {
1122
+ const key = qualityKeyFor(session);
1123
+ const current = contextQuality.get(key) ?? context_quality_1.EMPTY_CONTEXT_QUALITY;
1124
+ const next = (0, context_quality_1.foldContextQuality)(current, event);
1125
+ if (next === current)
1126
+ return;
1127
+ contextQuality.set(key, next);
1128
+ const type = String(event?.type ?? '');
1129
+ // The traced subset lives in `session-events.ts` beside the folded vocabulary, where both lists are
1130
+ // checked against the host's own `SessionEventMap`. Two hand-written copies of these names used to sit
1131
+ // in this plugin, and only one of them was ever going to get updated.
1132
+ if (session_events_1.TRACED_EVENTS.has(type)) {
1133
+ (0, logging_1.trace)('CONTEXT_QUALITY', {
1134
+ // DSH ids are `session-<uuid>`, so the first eight characters are the constant prefix and
1135
+ // identify nothing -- every session logged as "session-". Take the eight AFTER the prefix.
1136
+ session: key.startsWith('session-') ? key.slice(8, 16) : key.slice(0, 8),
1137
+ event: type,
1138
+ summary: (0, context_quality_1.describeContextQuality)(next),
1139
+ });
1140
+ // Once per turn, the compounding figure: content the worker produced and the architect never carried,
1141
+ // multiplied by the model calls it would have ridden on. The currency is context, not money -- see
1142
+ // `docs/experiment.md` for why the ledger stopped quoting dollars. `turn/start` is used because it is
1143
+ // already folded and traced, so this costs one branch rather than a second event subscription.
1144
+ if (type === 'turn/start') {
1145
+ (0, logging_1.trace)('CONTEXT_HYGIENE', {
1146
+ session: key.startsWith('session-') ? key.slice(8, 16) : key.slice(0, 8),
1147
+ line: tracker.contextHygiene(next.steps).line,
1148
+ });
1149
+ }
1150
+ }
1151
+ });
1152
+ // 2. Primary Thread (Architect) Request Hook: Pin primary thread to DeepSeek Cloud with native uncapped context and tool schema injection
1153
+ (0, host_events_1.onHost)(ctx, 'agent/request',
1154
+ // Parameters deliberately NOT annotated: both are inferred from the host's own signature for
1155
+ // `agent/request`, so reading a field the host does not send is now a compile error here.
1156
+ async (payload, next) => {
1157
+ // `next` is typed by the host as always a function, so the `{}` fallback is a runtime guard the
1158
+ // types cannot see through. Widening this local keeps the guard without pretending the branch is
1159
+ // reachable; everything else in this handler stays typed.
1160
+ const resolvedConfig = typeof next === 'function' ? await next() : {};
1161
+ const turn = payload?.turn;
1162
+ const agent = payload?.agent;
1163
+ let prompt = (turn !== undefined ? pendingTurnPrompts.get(turnPromptKey(payload, turn)) : '') || '';
1164
+ if (turn !== undefined) {
1165
+ pendingTurnPrompts.delete(turnPromptKey(payload, turn));
1166
+ }
1167
+ if (!prompt) {
1168
+ try {
1169
+ if (agent?.inbox?.nextTurn && Array.isArray(agent.inbox.nextTurn) && agent.inbox.nextTurn.length > 0) {
1170
+ const item = agent.inbox.nextTurn[agent.inbox.nextTurn.length - 1];
1171
+ prompt = item?.prompt || item?.text || item?.content || '';
1172
+ }
1173
+ }
1174
+ catch { }
1175
+ }
1176
+ if (!prompt && agent?.session)
1177
+ prompt = (0, delegation_1.extractPromptText)(agent.session);
1178
+ // `payload?.session` used to be tried here. The host's `agent/request` payload is
1179
+ // `{ agent, turn, step, signal }` -- there is no `session` on it, so that fallback never matched a
1180
+ // single request. Found by typing the handler against the host's own signature rather than by review.
1181
+ if (!prompt && payload)
1182
+ prompt = (0, delegation_1.extractPromptText)(payload);
1183
+ // Pre-flight DLP Firewall. This is a GATE, not a log line: a payload carrying
1184
+ // credentials is either refused outright or pinned to the local worker, but it
1185
+ // is never transmitted to the cloud. (Earlier versions logged "Blocking WAN
1186
+ // transmission" and then sent the payload anyway.)
1187
+ // Scan everything this session has said, not just the newest message: the host
1188
+ // re-sends the conversation on every request, so a clean latest message is not
1189
+ // evidence that the outbound payload is clean.
1190
+ const corpus = sessionPromptCorpus.get(corpusKeyFor(payload)) || '';
1191
+ const dlpSubject = corpus.length > prompt.length ? corpus : prompt;
1192
+ const dlpResult = scanDLP(dlpSubject, {
1193
+ entropyCheck: options?.entropyCheck,
1194
+ entropyMinBitsPerChar: options?.entropyMinBitsPerChar,
1195
+ entropyMinLength: options?.entropyMinLength,
1196
+ });
1197
+ const dlpTripped = config.enforceDLP && dlpResult.hasSensitiveData;
1198
+ // Entropy-only hits are medium confidence: always rerouted local (so they are
1199
+ // never transmitted) but never hard-blocked, because digests and base64 payloads
1200
+ // are legitimate content that merely looks random.
1201
+ const entropyOnly = dlpTripped && !dlpResult.highConfidence;
1202
+ // WHERE THIS WILL ACTUALLY LAND, decided before the role is applied and before anything is
1203
+ // transmitted. The reroute used to be committed here on entropy alone, which in the documented
1204
+ // cloud-only setup pinned the payload to the provider it was meant to be kept away from.
1205
+ // `classifyDestination` refuses instead when a reroute was requested but there is no distinct
1206
+ // local provider, or when the policy says block.
1207
+ const classification = (0, routing_1.classifyDestination)({
1208
+ provider: resolvedConfig?.provider || '',
1209
+ localProvider: config.localProvider,
1210
+ cloudProvider: config.cloudProvider,
1211
+ dlpTripped,
1212
+ entropyOnly,
1213
+ dlpAction: options?.dlpAction,
1214
+ });
1215
+ if (classification.decision === 'block') {
1216
+ // Name the pattern, not just the policy. An earlier throw said "credentials detected (API Key)",
1217
+ // and dropping that to a bare classification reason cost a test that asserts the message names
1218
+ // the offending pattern -- which is also what tells an operator what to remove.
1219
+ const violations = dlpResult.violations.join(', ');
1220
+ const why = violations
1221
+ ? 'credentials detected (' + violations + '). ' + (classification.reason || '')
1222
+ : classification.reason || 'policy says block';
1223
+ console.error('[DLP_FIREWALL_BLOCK] Refusing to transmit: ' + why + '. Nothing was sent to the cloud.');
1224
+ throw new Error('DLP firewall blocked this request: ' + why +
1225
+ '. Nothing was transmitted. This gate scans every user message it has seen in this session, ' +
1226
+ "so a credential that appeared in an earlier turn keeps blocking until the session is restarted.");
1227
+ }
1228
+ const rerouteLocal = classification.rerouteLocal;
1229
+ if (dlpTripped) {
1230
+ const violations = dlpResult.violations.join(', ');
1231
+ (0, logging_1.trace)('DLP_FIREWALL_TRIPPED', {
1232
+ violations,
1233
+ action: rerouteLocal ? 'reroute-local' : 'block',
1234
+ confidence: dlpResult.highConfidence ? 'high' : 'entropy-only',
1235
+ prompt: prompt.slice(0, 100),
1236
+ });
1237
+ // The old shouldBlock branch stood here. It is dead: the classification above already threw for
1238
+ // the same two cases -- a reroute requested with no distinct local provider, and an explicit
1239
+ // block policy -- and it throws with a reason that names the actual cause.
1240
+ console.warn(`[DLP_FIREWALL_REROUTE] Sensitive data (${violations}) pinned to the LOCAL worker; it will not reach the WAN.`);
1241
+ }
1242
+ // Which role is this request? The plugin used to treat every agent as the architect, which is
1243
+ // right for the architect and wrong for everything else: a lead configured to run locally would
1244
+ // be repinned to the cloud and told it was the architect. The DLP gate above runs either way, so
1245
+ // opting a provider out of the architect role does not opt it out of the firewall.
1246
+ const role = (0, roles_1.resolveAgentRole)({
1247
+ hostProvider: resolvedConfig?.provider,
1248
+ leadProviders: (0, roles_1.resolveLeadProviders)(options),
1249
+ });
1250
+ // Correlate the role with the agent, so a later tool call can be attributed. Best effort by
1251
+ // construction: the host does not expose lineage, so this is the plugin's own inference.
1252
+ (0, roles_1.rememberAgentRole)(agent?.id, role.role);
1253
+ const mutatedConfig = (0, roles_1.applyAgentRole)(resolvedConfig || {}, role, {
1254
+ cloudProvider: config.cloudProvider,
1255
+ cloudModel: config.cloudModel,
1256
+ localProvider: config.localProvider,
1257
+ localModel: config.localModel,
1258
+ rerouteLocal,
1259
+ architectInstruction: profiles_1.PROFILES.ARCHITECT.systemInstruction,
1260
+ workerTool: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA,
1261
+ });
1262
+ // Rule 8: source may not reach the cloud. The read guard covers pulling delegated code back, and
1263
+ // contextFiles injects into the worker; this covers the blunt route — source sitting in the
1264
+ // outbound payload because it was typed into a cloud-bound conversation.
1265
+ const destination = rerouteLocal ||
1266
+ String(mutatedConfig.provider || '').toLowerCase() ===
1267
+ String(config.localProvider || '').toLowerCase()
1268
+ ? 'local'
1269
+ : 'cloud';
1270
+ const egressDetection = (0, roles_1.detectSourceEgress)(dlpSubject, {
1271
+ minLines: options?.sourceEgressMinLines,
1272
+ });
1273
+ const egress = (0, roles_1.evaluateSourceEgress)(options?.sourceEgress ?? 'deny', egressDetection, destination);
1274
+ if (egress.kind !== 'allow') {
1275
+ let permitted = false;
1276
+ if (egress.kind === 'ask') {
1277
+ try {
1278
+ const approvalService = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
1279
+ if (approvalService && typeof approvalService.request === 'function' && agent) {
1280
+ const outcome = await approvalService.request({
1281
+ agent,
1282
+ toolName: 'agent/request',
1283
+ reason: `This cloud-bound request carries source: ${egress.reason} Source is not supposed ` +
1284
+ `to reach the cloud (rule 8). Approve only if you mean to transmit it.`,
1285
+ ...(payload?.signal ? { signal: payload.signal } : {}),
1286
+ });
1287
+ permitted = outcome === 'allowed-once';
1288
+ }
1289
+ }
1290
+ catch (err) {
1291
+ console.warn('[SOURCE_EGRESS] approval request failed; failing closed:', err?.message || err);
1292
+ permitted = false;
1293
+ }
1294
+ }
1295
+ if (!permitted) {
1296
+ (0, logging_1.trace)('SOURCE_EGRESS_BLOCKED', {
1297
+ blocks: egressDetection.blocks,
1298
+ languages: egressDetection.languages,
1299
+ destination,
1300
+ action: options?.sourceEgress ?? 'deny',
1301
+ });
1302
+ throw new Error(`Source may not reach the cloud (rule 8): ${egress.reason} Nothing was transmitted. ` +
1303
+ `This gate reads every user message the session has sent, so a block from an earlier ` +
1304
+ `turn keeps it closed until the session is restarted.`);
1305
+ }
1306
+ }
1307
+ (0, logging_1.trace)(role.role === 'lead'
1308
+ ? 'HOOK_EXIT: LEAD_LEFT_AS_CONFIGURED (agent/request)'
1309
+ : rerouteLocal
1310
+ ? 'HOOK_EXIT: DLP_PINNED_LOCAL (agent/request)'
1311
+ : 'HOOK_EXIT: ARCHITECT_CLOUD_PINNED (agent/request)', {
1312
+ role: role.role,
1313
+ roleReason: role.reason,
1314
+ provider: mutatedConfig.provider,
1315
+ model: mutatedConfig.model,
1316
+ uncappedContextWindow: role.role === 'architect',
1317
+ toolsCount: mutatedConfig.tools?.length || 0,
1318
+ });
1319
+ // The host expects `LlmCallConfig` here. `applyAgentRole` returns a looser record, so this is an
1320
+ // ASSERTION rather than a check: the plugin cannot prove it returns a valid config, and DSH does not
1321
+ // verify it either. Named rather than hidden -- the payload on the way in IS checked, which is where
1322
+ // the dead `payload.session` read was found.
1323
+ return mutatedConfig;
1324
+ }, { prepend: true });
1325
+ // Architect usage is deliberately NOT recorded in this ledger. Three subscriptions that tried to are
1326
+ // gone, because none of them could ever have fired -- checked against the installed host, not assumed:
1327
+ //
1328
+ // `agent/post-step` and `agent/step-finish` do not exist. Neither name appears anywhere in the
1329
+ // installed `@deepseek-ai/*` packages; this plugin invented them.
1330
+ //
1331
+ // `agent/assistant-stream` does exist, but its frames are `start` / `chunk` / `end`
1332
+ // (`AssistantStreamFrame` in `dsh-agent`) and none carries `usage`. That stream is "presentation data
1333
+ // rather than the replay source"; the durable settlement is `assistant/message`, which does carry
1334
+ // `usage?: TokenUsage`.
1335
+ //
1336
+ // So `cloudTurns`, `architectTurns`, `totalCloudTokens` and `totalSpendUSD` are permanently zero, and
1337
+ // the ledger's `scope` field now says so rather than leaving them to read as measured data.
1338
+ //
1339
+ // Reviving them was considered and rejected, not overlooked. It would mean a ledger write on every
1340
+ // architect model call -- 980 in one observed session -- to produce a dollar figure this plugin no
1341
+ // longer claims anything about. The architect side *is* measured, by the context-quality fold off the
1342
+ // same `session/event` subscription used above; it is measured as context, and it is not money.
1343
+ //
1344
+ // The general fix for this whole class -- a subscription to an event the host does not emit -- is to
1345
+ // type `ctx.on` against the host's `Events` interface so that such a name cannot compile. That is the
1346
+ // open half of the host contract: ROADMAP item 21 covers session event TYPES, not hook names.
1347
+ }
1348
+ // The patch engine is configured inside ./delegation.ts, where both halves now live.
1349
+ const pluginExport = {
1350
+ name: exports.name,
1351
+ inject: exports.inject,
1352
+ using: exports.using,
1353
+ apply,
1354
+ LocalRouter,
1355
+ SavingsTracker: savings_tracker_1.SavingsTracker,
1356
+ scanDLP,
1357
+ delegateWorker: delegation_1.delegateWorker,
1358
+ extractAndEmitFiles: emission_1.extractAndEmitFiles,
1359
+ runSandboxVerification: verification_1.runSandboxVerification,
1360
+ parseTestOutput: verification_1.parseTestOutput,
1361
+ sha256File: contracts_1.sha256File,
1362
+ resolveContextFiles: context_1.resolveContextFiles,
1363
+ parseSearchReplaceBlocks: delegation_1.parseSearchReplaceBlocks,
1364
+ applySearchReplaceBlocks: delegation_1.applySearchReplaceBlocks,
1365
+ MIN_SEARCH_CHARS: delegation_1.MIN_SEARCH_CHARS,
1366
+ DEFAULT_CONTEXT_MAX_BYTES: context_1.DEFAULT_CONTEXT_MAX_BYTES,
1367
+ resolveContractFiles: contracts_1.resolveContractFiles,
1368
+ contractFileHashes: contracts_1.contractFileHashes,
1369
+ contractViolations: contracts_1.contractViolations,
1370
+ resolveDelegateStatus: delegation_1.resolveDelegateStatus,
1371
+ resolveVerificationRepeats: delegation_1.resolveVerificationRepeats,
1372
+ MAX_VERIFICATION_REPEATS: delegation_1.MAX_VERIFICATION_REPEATS,
1373
+ resolveLeadProviders: roles_1.resolveLeadProviders,
1374
+ evaluateDelegatedReadPolicy: guard_1.evaluateDelegatedReadPolicy,
1375
+ rememberAgentRole: roles_1.rememberAgentRole,
1376
+ roleForAgent: roles_1.roleForAgent,
1377
+ resetAgentRoles: roles_1.resetAgentRoles,
1378
+ describeSourceRead: roles_1.describeSourceRead,
1379
+ AGENT_ROLE_LIMIT: roles_1.AGENT_ROLE_LIMIT,
1380
+ detectSourceEgress: roles_1.detectSourceEgress,
1381
+ evaluateSourceEgress: roles_1.evaluateSourceEgress,
1382
+ DEFAULT_SOURCE_EGRESS_MIN_LINES: roles_1.DEFAULT_SOURCE_EGRESS_MIN_LINES,
1383
+ resolveDelegatedRegistryPath: contracts_1.resolveDelegatedRegistryPath,
1384
+ parseDelegatedRegistry: contracts_1.parseDelegatedRegistry,
1385
+ mergeDelegatedRecords: contracts_1.mergeDelegatedRecords,
1386
+ pruneDelegatedRecords: contracts_1.pruneDelegatedRecords,
1387
+ saveDelegatedRegistry: contracts_1.saveDelegatedRegistry,
1388
+ loadDelegatedRegistry: contracts_1.loadDelegatedRegistry,
1389
+ rememberDelegated: contracts_1.rememberDelegated,
1390
+ resolveAgentRole: roles_1.resolveAgentRole,
1391
+ applyArchitectConfig: roles_1.applyArchitectConfig,
1392
+ applyAgentRole: roles_1.applyAgentRole,
1393
+ DELEGATE_WORKER_SCHEMA: delegation_1.DELEGATE_WORKER_SCHEMA,
1394
+ DELEGATE_WORKER_OPENAI_SCHEMA: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA,
1395
+ PROFILES: profiles_1.PROFILES,
1396
+ default: apply,
1397
+ };
1398
+ exports.default = pluginExport;