@goodandready/dsh-approval-gate 0.1.25 → 0.1.27

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.
@@ -0,0 +1,333 @@
1
+ // lib/timeline-service.js
2
+ // Session Audit Timeline, Learned Rules & WebServer REST Service for DSH Approval Gate (#82, #60, #74, #86, #84, #49, #116).
3
+
4
+ import { generateDiffLines, groupHunks, formatUnifiedDiff } from "./diff-viewer.js";
5
+
6
+ /**
7
+ * @typedef {Object} TimelineEvent
8
+ * @property {string} id
9
+ * @property {string} sessionId
10
+ * @property {string | number} [turnId]
11
+ * @property {number} timestamp
12
+ * @property {string} tool
13
+ * @property {string} [command]
14
+ * @property {string} [snippet]
15
+ * @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
16
+ * @property {string} [stage]
17
+ * @property {string} [reason]
18
+ * @property {number} [durationMs]
19
+ * @property {number | null} [expiresAt]
20
+ * @property {number | null} [countdownSec]
21
+ * @property {Object} [diffMeta]
22
+ */
23
+
24
+ /**
25
+ * TimelineService: records call history per session and exposes query & webServer routes (#82).
26
+ */
27
+ export class TimelineService {
28
+ /**
29
+ * @param {{ maxEventsPerSession?: number, turnCardTracker?: any, learner?: any, engine?: any }} [options]
30
+ */
31
+ constructor(options = {}) {
32
+ this.maxEventsPerSession = options.maxEventsPerSession || 500;
33
+ this.turnCardTracker = options.turnCardTracker || null;
34
+ this.learner = options.learner || null;
35
+ this.engine = options.engine || null;
36
+ /** @type {Map<string, TimelineEvent[]>} */
37
+ this._sessions = new Map();
38
+ this._nextId = 1;
39
+ this.customRules = [];
40
+ this.whitelistPaths = [];
41
+ }
42
+
43
+ /**
44
+ * Records a security gate event into session timeline (#82).
45
+ *
46
+ * @param {Object} raw
47
+ * @returns {TimelineEvent}
48
+ */
49
+ recordEvent(raw) {
50
+ const sid = String(raw.sessionId || "default");
51
+ const tid = raw.turnId !== undefined && raw.turnId !== null ? raw.turnId : "active";
52
+
53
+ let list = this._sessions.get(sid);
54
+ if (!list) {
55
+ list = [];
56
+ this._sessions.set(sid, list);
57
+ }
58
+
59
+ const id = `evt_${Date.now()}_${this._nextId++}`;
60
+ const timestamp = raw.timestamp || Date.now();
61
+ let countdownSec = null;
62
+ let expiresAt = null;
63
+
64
+ if (raw.verdict === "ask" && typeof raw.timeoutMs === "number" && raw.timeoutMs > 0) {
65
+ expiresAt = timestamp + raw.timeoutMs;
66
+ countdownSec = Math.max(0, Math.round(raw.timeoutMs / 1000));
67
+ }
68
+
69
+ /** @type {TimelineEvent} */
70
+ const evt = {
71
+ id,
72
+ sessionId: sid,
73
+ turnId: tid,
74
+ timestamp,
75
+ tool: raw.tool || "bash",
76
+ command: raw.command || "",
77
+ snippet: raw.snippet || "",
78
+ verdict: raw.verdict || "allow",
79
+ stage: raw.stage || "",
80
+ reason: raw.reason || "",
81
+ durationMs: raw.durationMs || 0,
82
+ expiresAt,
83
+ countdownSec,
84
+ diffMeta: raw.diffMeta || null,
85
+ };
86
+
87
+ list.push(evt);
88
+ if (list.length > this.maxEventsPerSession) {
89
+ list.splice(0, list.length - this.maxEventsPerSession);
90
+ }
91
+
92
+ // Also forward to TurnCardTracker if attached (#86)
93
+ if (this.turnCardTracker && typeof this.turnCardTracker.recordCall === "function") {
94
+ this.turnCardTracker.recordCall(sid, tid, {
95
+ tool: evt.tool,
96
+ command: evt.command,
97
+ snippet: evt.snippet,
98
+ verdict: evt.verdict,
99
+ reason: evt.reason,
100
+ durationMs: evt.durationMs,
101
+ timestamp: evt.timestamp,
102
+ });
103
+ }
104
+
105
+ return evt;
106
+ }
107
+
108
+ /**
109
+ * Retrieves timeline events for a given session with optional filtering (#82).
110
+ *
111
+ * @param {string} sessionId
112
+ * @param {Object} [options]
113
+ * @param {'all' | 'blocked' | 'allowed' | 'ask'} [options.filter='all']
114
+ * @param {number} [options.limit=100]
115
+ * @param {number} [options.offset=0]
116
+ * @returns {{ total: number, events: TimelineEvent[] }}
117
+ */
118
+ getEvents(sessionId, options = {}) {
119
+ const sid = String(sessionId || "default");
120
+ const list = this._sessions.get(sid) || [];
121
+ const filter = options.filter || "all";
122
+ const limit = typeof options.limit === "number" ? Math.max(1, options.limit) : 100;
123
+ const offset = typeof options.offset === "number" ? Math.max(0, options.offset) : 0;
124
+
125
+ let filtered = list;
126
+ if (filter === "blocked") {
127
+ filtered = list.filter((e) => e.verdict === "deny");
128
+ } else if (filter === "allowed") {
129
+ filtered = list.filter((e) => e.verdict === "allow");
130
+ } else if (filter === "ask") {
131
+ filtered = list.filter((e) => e.verdict === "ask");
132
+ }
133
+
134
+ const total = filtered.length;
135
+ const reversed = filtered.slice().reverse();
136
+ const paginated = reversed.slice(offset, offset + limit);
137
+
138
+ return { total, events: paginated };
139
+ }
140
+
141
+ /**
142
+ * Clears timeline events for a specific session.
143
+ *
144
+ * @param {string} sessionId
145
+ */
146
+ clearSession(sessionId) {
147
+ const sid = String(sessionId || "default");
148
+ this._sessions.delete(sid);
149
+ }
150
+
151
+ /**
152
+ * Registers REST HTTP routes on ctx.webServer for browser UI interaction (#82, #74, #86, #84, #49, #116).
153
+ *
154
+ * @param {any} ctx Cordis context
155
+ * @param {Object} [options]
156
+ * @param {string} [options.prefix='/dsh-approval-gate']
157
+ */
158
+ registerRoutes(ctx, options = {}) {
159
+ const prefix = options.prefix || "/dsh-approval-gate";
160
+ if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== "function") {
161
+ return;
162
+ }
163
+
164
+ const jsonResponse = (res, status, data) => {
165
+ res.writeHead(status, {
166
+ "Content-Type": "application/json; charset=utf-8",
167
+ "Cache-Control": "no-cache, no-store, must-revalidate",
168
+ });
169
+ res.end(JSON.stringify(data));
170
+ };
171
+
172
+ const parseBody = (req) =>
173
+ new Promise((resolve) => {
174
+ let body = "";
175
+ req.on("data", (c) => { body += c; });
176
+ req.on("end", () => {
177
+ try { resolve(JSON.parse(body || "{}")); }
178
+ catch { resolve({}); }
179
+ });
180
+ });
181
+
182
+ // 1. GET /timeline
183
+ const timelineRoute = () =>
184
+ ctx.webServer.register({
185
+ path: `${prefix}/timeline`,
186
+ method: "GET",
187
+ handler: async (req, res) => {
188
+ try {
189
+ const url = new URL(req.url, "http://127.0.0.1");
190
+ const sid = url.searchParams.get("sessionId") || "default";
191
+ const filter = url.searchParams.get("filter") || "all";
192
+ const limit = parseInt(url.searchParams.get("limit") || "100", 10);
193
+ const data = this.getEvents(sid, { filter, limit });
194
+ jsonResponse(res, 200, { ok: true, sessionId: sid, ...data });
195
+ } catch (err) {
196
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
197
+ }
198
+ },
199
+ });
200
+
201
+ // 2. GET /turn-card
202
+ const turnCardRoute = () =>
203
+ ctx.webServer.register({
204
+ path: `${prefix}/turn-card`,
205
+ method: "GET",
206
+ handler: async (req, res) => {
207
+ try {
208
+ const url = new URL(req.url, "http://127.0.0.1");
209
+ const sid = url.searchParams.get("sessionId") || "default";
210
+ const tid = url.searchParams.get("turnId") || "active";
211
+ const lang = url.searchParams.get("lang") || "en";
212
+ let card = null;
213
+ if (this.turnCardTracker && typeof this.turnCardTracker.buildTurnCard === "function") {
214
+ card = this.turnCardTracker.buildTurnCard(sid, tid, { lang });
215
+ }
216
+ jsonResponse(res, 200, { ok: true, sessionId: sid, turnId: tid, card });
217
+ } catch (err) {
218
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
219
+ }
220
+ },
221
+ });
222
+
223
+ // 3. POST /diff
224
+ const diffRoute = () =>
225
+ ctx.webServer.register({
226
+ path: `${prefix}/diff`,
227
+ method: "POST",
228
+ handler: async (req, res) => {
229
+ try {
230
+ const parsed = await parseBody(req);
231
+ const oldText = parsed.oldText || "";
232
+ const newText = parsed.newText || "";
233
+ const contextLines = typeof parsed.contextLines === "number" ? parsed.contextLines : 3;
234
+ const diffLines = generateDiffLines(oldText, newText);
235
+ const hunks = groupHunks(diffLines, { contextLines });
236
+ const diffText = formatUnifiedDiff(hunks, {
237
+ oldFile: parsed.filename ? `a/${parsed.filename}` : "a/original",
238
+ newFile: parsed.filename ? `b/${parsed.filename}` : "b/modified",
239
+ });
240
+ jsonResponse(res, 200, { ok: true, hunks, diffText, linesCount: diffLines.length });
241
+ } catch (err) {
242
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
243
+ }
244
+ },
245
+ });
246
+
247
+ // 4. GET /learned-rules (#84)
248
+ const learnedRulesRoute = () =>
249
+ ctx.webServer.register({
250
+ path: `${prefix}/learned-rules`,
251
+ method: "GET",
252
+ handler: async (req, res) => {
253
+ try {
254
+ const url = new URL(req.url, "http://127.0.0.1");
255
+ const sid = url.searchParams.get("sessionId") || "default";
256
+ const rules = (this.learner && typeof this.learner.getLearnedRules === "function")
257
+ ? this.learner.getLearnedRules(sid)
258
+ : [];
259
+ jsonResponse(res, 200, { ok: true, sessionId: sid, rules });
260
+ } catch (err) {
261
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
262
+ }
263
+ },
264
+ });
265
+
266
+ // 5. POST /learned-rules/revoke (#84)
267
+ const revokeLearnedRoute = () =>
268
+ ctx.webServer.register({
269
+ path: `${prefix}/learned-rules/revoke`,
270
+ method: "POST",
271
+ handler: async (req, res) => {
272
+ try {
273
+ const body = await parseBody(req);
274
+ const sid = body.sessionId || "default";
275
+ const key = body.key || "";
276
+ let revoked = false;
277
+ if (this.learner && typeof this.learner.revoke === "function" && key) {
278
+ revoked = this.learner.revoke(sid, key);
279
+ }
280
+ jsonResponse(res, 200, { ok: true, revoked, key });
281
+ } catch (err) {
282
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
283
+ }
284
+ },
285
+ });
286
+
287
+ // 6. GET /mode (#116)
288
+ const modeRoute = () =>
289
+ ctx.webServer.register({
290
+ path: `${prefix}/mode`,
291
+ method: "GET",
292
+ handler: async (req, res) => {
293
+ try {
294
+ const mode = this.engine?.mode || "hybrid";
295
+ jsonResponse(res, 200, { ok: true, mode });
296
+ } catch (err) {
297
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
298
+ }
299
+ },
300
+ });
301
+
302
+ // 7. GET & POST /rules (#49)
303
+ const rulesRoute = () =>
304
+ ctx.webServer.register({
305
+ path: `${prefix}/rules`,
306
+ method: "GET",
307
+ handler: async (req, res) => {
308
+ jsonResponse(res, 200, { ok: true, rules: this.customRules, whitelistPaths: this.whitelistPaths });
309
+ },
310
+ });
311
+ const updateRulesRoute = () =>
312
+ ctx.webServer.register({
313
+ path: `${prefix}/rules`,
314
+ method: "POST",
315
+ handler: async (req, res) => {
316
+ try {
317
+ const body = await parseBody(req);
318
+ if (Array.isArray(body.rules)) this.customRules = body.rules;
319
+ if (Array.isArray(body.whitelistPaths)) this.whitelistPaths = body.whitelistPaths;
320
+ jsonResponse(res, 200, { ok: true, rules: this.customRules, whitelistPaths: this.whitelistPaths });
321
+ } catch (err) {
322
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
323
+ }
324
+ },
325
+ });
326
+
327
+ const routes = [timelineRoute, turnCardRoute, diffRoute, learnedRulesRoute, revokeLearnedRoute, modeRoute, rulesRoute, updateRulesRoute];
328
+ for (const r of routes) {
329
+ if (typeof ctx.effect === "function") ctx.effect(r, `dsh-approval-gate: ${r.name}`);
330
+ else r();
331
+ }
332
+ }
333
+ }
@@ -0,0 +1,286 @@
1
+ // lib/turn-card.js
2
+ // TurnCard Round Summary Engine for DSH Approval Gate (#86).
3
+ // Aggregates tool hits, blocked rules, asked confirmations, and builds a comprehensive summary per agent turn.
4
+
5
+ /**
6
+ * @typedef {Object} TurnCallRecord
7
+ * @property {string} [tool]
8
+ * @property {string} [command]
9
+ * @property {string} [snippet]
10
+ * @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
11
+ * @property {string} [reason]
12
+ * @property {string} [rule]
13
+ * @property {number} [durationMs]
14
+ * @property {number} [timestamp]
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} RuleHitSummary
19
+ * @property {string} rule
20
+ * @property {number} count
21
+ * @property {string[]} reasons
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} TurnCard
26
+ * @property {string} sessionId
27
+ * @property {string | number} turnId
28
+ * @property {number} timestamp
29
+ * @property {'clean' | 'warnings' | 'blocked'} status
30
+ * @property {number} totalCalls
31
+ * @property {number} allowedCalls
32
+ * @property {number} blockedCalls
33
+ * @property {number} askedCalls
34
+ * @property {number} preemptedCalls
35
+ * @property {RuleHitSummary[]} ruleHits
36
+ * @property {TurnCallRecord[]} blockedDetails
37
+ * @property {string} summaryText
38
+ */
39
+
40
+ /**
41
+ * Formats a localized or human-readable summary string for a TurnCard (#86).
42
+ *
43
+ * @param {TurnCard} card
44
+ * @param {'en' | 'ru' | 'zh'} [lang='en']
45
+ * @returns {string}
46
+ */
47
+ export function formatTurnCardSummary(card, lang = "en") {
48
+ if (!card) return "";
49
+ const tid = card.turnId !== undefined && card.turnId !== null ? card.turnId : "current";
50
+
51
+ if (lang === "ru") {
52
+ if (card.status === "clean") {
53
+ return `Раунд #${tid}: ${card.totalCalls} вызовов, все одобрены без замечаний.`;
54
+ }
55
+ const parts = [`Раунд #${tid}: ${card.totalCalls} вызовов`];
56
+ if (card.blockedCalls > 0) parts.push(`заблокировано: ${card.blockedCalls}`);
57
+ if (card.askedCalls > 0) parts.push(`запросов подтверждения: ${card.askedCalls}`);
58
+ if (card.ruleHits && card.ruleHits.length > 0) {
59
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
60
+ parts.push(`сработавшие правила: [${rules}]`);
61
+ }
62
+ return parts.join("; ") + ".";
63
+ }
64
+
65
+ if (lang === "zh") {
66
+ if (card.status === "clean") {
67
+ return `轮次 #${tid}: 共 ${card.totalCalls} 次调用,全部安全通过。`;
68
+ }
69
+ const parts = [`轮次 #${tid}: 共 ${card.totalCalls} 次调用`];
70
+ if (card.blockedCalls > 0) parts.push(`拦截: ${card.blockedCalls}`);
71
+ if (card.askedCalls > 0) parts.push(`需人工确认: ${card.askedCalls}`);
72
+ if (card.ruleHits && card.ruleHits.length > 0) {
73
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
74
+ parts.push(`命中规则: [${rules}]`);
75
+ }
76
+ return parts.join(";") + "。";
77
+ }
78
+
79
+ // Default English
80
+ if (card.status === "clean") {
81
+ return `Turn #${tid}: ${card.totalCalls} calls, all allowed cleanly.`;
82
+ }
83
+ const parts = [`Turn #${tid}: ${card.totalCalls} calls`];
84
+ if (card.blockedCalls > 0) parts.push(`${card.blockedCalls} blocked`);
85
+ if (card.askedCalls > 0) parts.push(`${card.askedCalls} confirmation prompts`);
86
+ if (card.ruleHits && card.ruleHits.length > 0) {
87
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
88
+ parts.push(`rule hits: [${rules}]`);
89
+ }
90
+ return parts.join(", ") + ".";
91
+ }
92
+
93
+ /**
94
+ * TurnCardTracker: tracks tool calls per agent turn and builds structured summaries (#86).
95
+ */
96
+ export class TurnCardTracker {
97
+ /**
98
+ * @param {{ maxRetainedTurns?: number }} [options]
99
+ */
100
+ constructor(options = {}) {
101
+ this.maxRetainedTurns = options.maxRetainedTurns || 50;
102
+ /** @type {Map<string, Map<string | number, TurnCallRecord[]>>} */
103
+ this._sessions = new Map();
104
+ /** @type {Map<string, Map<string | number, TurnCard>>} */
105
+ this._cards = new Map();
106
+ }
107
+
108
+ /**
109
+ * Records a tool call within a specific session and turn.
110
+ *
111
+ * @param {string} sessionId
112
+ * @param {string | number} turnId
113
+ * @param {TurnCallRecord} record
114
+ */
115
+ recordCall(sessionId, turnId, record) {
116
+ const sid = String(sessionId || "default");
117
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
118
+
119
+ let sessionTurns = this._sessions.get(sid);
120
+ if (!sessionTurns) {
121
+ sessionTurns = new Map();
122
+ this._sessions.set(sid, sessionTurns);
123
+ }
124
+
125
+ let calls = sessionTurns.get(tid);
126
+ if (!calls) {
127
+ calls = [];
128
+ sessionTurns.set(tid, calls);
129
+ }
130
+
131
+ calls.push({
132
+ tool: record.tool || "unknown",
133
+ command: record.command || "",
134
+ snippet: record.snippet || "",
135
+ verdict: record.verdict || "allow",
136
+ reason: record.reason || "",
137
+ rule: record.rule || record.reason || "",
138
+ durationMs: record.durationMs || 0,
139
+ timestamp: record.timestamp || Date.now(),
140
+ });
141
+
142
+ // Invalidate cached card for this turn
143
+ const sessionCards = this._cards.get(sid);
144
+ if (sessionCards) {
145
+ sessionCards.delete(tid);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Builds the TurnCard for a specific turn in a session (#86).
151
+ *
152
+ * @param {string} sessionId
153
+ * @param {string | number} turnId
154
+ * @param {Object} [options]
155
+ * @param {'en' | 'ru' | 'zh'} [options.lang='en']
156
+ * @returns {TurnCard}
157
+ */
158
+ buildTurnCard(sessionId, turnId, options = {}) {
159
+ const sid = String(sessionId || "default");
160
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
161
+ const lang = options.lang || "en";
162
+
163
+ let sessionCards = this._cards.get(sid);
164
+ if (!sessionCards) {
165
+ sessionCards = new Map();
166
+ this._cards.set(sid, sessionCards);
167
+ }
168
+
169
+ const cached = sessionCards.get(tid);
170
+ if (cached) return cached;
171
+
172
+ const sessionTurns = this._sessions.get(sid);
173
+ const calls = (sessionTurns && sessionTurns.get(tid)) || [];
174
+
175
+ let totalCalls = calls.length;
176
+ let allowedCalls = 0;
177
+ let blockedCalls = 0;
178
+ let askedCalls = 0;
179
+ let preemptedCalls = 0;
180
+ /** @type {Map<string, { count: number, reasons: Set<string> }>} */
181
+ const ruleHitMap = new Map();
182
+ /** @type {TurnCallRecord[]} */
183
+ const blockedDetails = [];
184
+
185
+ for (const c of calls) {
186
+ if (c.verdict === "deny") {
187
+ blockedCalls++;
188
+ blockedDetails.push(c);
189
+ } else if (c.verdict === "ask") {
190
+ askedCalls++;
191
+ } else if (c.verdict === "preempted") {
192
+ preemptedCalls++;
193
+ } else {
194
+ allowedCalls++;
195
+ }
196
+
197
+ if (c.rule && c.verdict !== "allow") {
198
+ let entry = ruleHitMap.get(c.rule);
199
+ if (!entry) {
200
+ entry = { count: 0, reasons: new Set() };
201
+ ruleHitMap.set(c.rule, entry);
202
+ }
203
+ entry.count++;
204
+ if (c.reason) entry.reasons.add(c.reason);
205
+ }
206
+ }
207
+
208
+ /** @type {RuleHitSummary[]} */
209
+ const ruleHits = Array.from(ruleHitMap.entries()).map(([rule, data]) => ({
210
+ rule,
211
+ count: data.count,
212
+ reasons: Array.from(data.reasons),
213
+ }));
214
+
215
+ /** @type {'clean' | 'warnings' | 'blocked'} */
216
+ let status = "clean";
217
+ if (blockedCalls > 0) {
218
+ status = "blocked";
219
+ } else if (askedCalls > 0) {
220
+ status = "warnings";
221
+ }
222
+
223
+ /** @type {TurnCard} */
224
+ const card = {
225
+ sessionId: sid,
226
+ turnId: tid,
227
+ timestamp: Date.now(),
228
+ status,
229
+ totalCalls,
230
+ allowedCalls,
231
+ blockedCalls,
232
+ askedCalls,
233
+ preemptedCalls,
234
+ ruleHits,
235
+ blockedDetails,
236
+ summaryText: "",
237
+ };
238
+
239
+ card.summaryText = formatTurnCardSummary(card, lang);
240
+ sessionCards.set(tid, card);
241
+ return card;
242
+ }
243
+
244
+ /**
245
+ * Returns all TurnCards for a session.
246
+ *
247
+ * @param {string} sessionId
248
+ * @returns {TurnCard[]}
249
+ */
250
+ getTurnCards(sessionId) {
251
+ const sid = String(sessionId || "default");
252
+ const sessionTurns = this._sessions.get(sid);
253
+ if (!sessionTurns) return [];
254
+ const list = [];
255
+ for (const tid of sessionTurns.keys()) {
256
+ list.push(this.buildTurnCard(sid, tid));
257
+ }
258
+ return list;
259
+ }
260
+
261
+ /**
262
+ * Clears tracking for a given turn.
263
+ *
264
+ * @param {string} sessionId
265
+ * @param {string | number} turnId
266
+ */
267
+ clearTurn(sessionId, turnId) {
268
+ const sid = String(sessionId || "default");
269
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
270
+ const st = this._sessions.get(sid);
271
+ if (st) st.delete(tid);
272
+ const sc = this._cards.get(sid);
273
+ if (sc) sc.delete(tid);
274
+ }
275
+
276
+ /**
277
+ * Clears tracking for an entire session.
278
+ *
279
+ * @param {string} sessionId
280
+ */
281
+ clearSession(sessionId) {
282
+ const sid = String(sessionId || "default");
283
+ this._sessions.delete(sid);
284
+ this._cards.delete(sid);
285
+ }
286
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
8
8
  ".": "./lib/index.js",
9
9
  "./package.json": "./package.json",
10
- "./cordis.patch.yml": "./cordis.patch.yml"
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./client": "./lib/client.js"
11
12
  },
12
13
  "files": [
13
14
  "lib",
@@ -23,6 +24,14 @@
23
24
  "dsh": {
24
25
  "bundle": {
25
26
  "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-slots",
33
+ "@deepseek-ai/dsh-client-locale"
34
+ ]
26
35
  }
27
36
  },
28
37
  "license": "MIT",
@@ -57,4 +66,4 @@
57
66
  "bin": {
58
67
  "dsh-approval-eval": "./bin/eval.mjs"
59
68
  }
60
- }
69
+ }