@duke-dsh-plugins/dsh-agent-approval 1.4.2 → 1.6.0

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.
package/index.js CHANGED
@@ -31,15 +31,28 @@
31
31
  * `callId`) plus the asker's stated reason. A rejection must name the
32
32
  * concrete, credible risk the operation creates (destructive /
33
33
  * irreversible / out-of-scope / dishonest); vague unease is approved.
34
+ * v1.6.0: the judge can alternatively be the TypeSafe Jev "System One"
35
+ * decision model (synthetic provider id `typesafe`) — a direct HTTP
36
+ * call that answers typed Choice/Noul questions with calibrated
37
+ * probabilities; a confidence below the configured gate resolves
38
+ * fail-closed like any other fault (see `_judgeWithJev`).
34
39
  *
35
40
  * 3. FAIL CLOSED — any infrastructure fault, timeout, malformed verdict, or
36
41
  * cancellation maps to the fail-closed approval outcomes
37
42
  * (`unavailable` / `cancelled`), never to a grant.
38
43
  *
39
- * 4. AUDIT — every decision is recorded (memory ring + JSONL under
40
- * DSH_HOME) and shown in the Settings page; the judge's own child
41
- * session id is kept so the full reasoning trail can be inspected in
42
- * the session list.
44
+ * 4. AUDIT — every decision is appended to a SIDECAR file inside the
45
+ * requesting session's OWN persistence directory
46
+ * (`<sessionDir>/agent-approval.jsonl`, resolved via
47
+ * `sessionPersistence.locate`), so the audit trail follows the session
48
+ * exactly: it survives restarts with the session, disappears when the
49
+ * session is deleted, and NEVER touches the durable event log — no
50
+ * custom events written, none read (the log's strict event-type
51
+ * vocabulary makes plugin-defined types unsafe, and per project ruling
52
+ * session.jsonl.zstd carries zero plugin data). The conversation
53
+ * window's「审批」tab (next to 轨迹) folds those records per session;
54
+ * the judge's own child session id is kept so the full reasoning trail
55
+ * can be inspected in the session list.
43
56
  *
44
57
  * Mount on the HOST plane (profile `cordis.patch.yml` insert row): the
45
58
  * approval waterfall listener must be unscoped to see every live agent, and
@@ -50,7 +63,7 @@ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
50
63
  import { Service } from "@deepseek-ai/cordis";
51
64
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
52
65
  import { homedir } from "node:os";
53
- import { join } from "node:path";
66
+ import { dirname, join } from "node:path";
54
67
 
55
68
  // ---- constants --------------------------------------------------------------
56
69
 
@@ -66,18 +79,90 @@ const PRESET_NAME = "agent-approval";
66
79
  const DEFAULT_TIMEOUT_MS = 120000;
67
80
  const MIN_TIMEOUT_MS = 30000;
68
81
  const MAX_TIMEOUT_MS = 600000;
69
- /** In-memory audit ring size (the Settings page shows the latest 50). */
70
- const MAX_RECORDS = 200;
71
82
  /**
72
- * On-disk persistence: one JSON object per line in records.jsonl plus the
73
- * judge settings in config.json. Lives under DSH_HOME (same resolution as
74
- * the plugin's own README documents), outside any profile's node_modules so
75
- * reinstalls and upgrades never touch it.
83
+ * v1.5.1, tightened in v1.5.2: audit records live in a SIDECAR FILE inside
84
+ * the session's OWN persistence directory (`<sessionDir>/agent-approval.jsonl`,
85
+ * resolved via `sessionPersistence.locate(header)`), so they still follow the
86
+ * session exactly — restored/kept with it, gone when the session directory is
87
+ * deleted. The durable event log (session.jsonl.zstd) is NEVER read for
88
+ * records and NEVER written by this plugin: writing custom event types into
89
+ * the log (v1.5.0's approach) is NOT viable — the persistence read path
90
+ * refuses a whole log containing an event type outside
91
+ * `KNOWN_SESSION_EVENT_TYPES` unless the envelope carries `ignorable: true`,
92
+ * and the live-session writer `session.append()` cannot set that marker —
93
+ * the first judged escalation made the session unresumable (2026-09-06, two
94
+ * poisoned log events repaired in place). Per the final ruling: the log
95
+ * carries ZERO plugin-defined data, and the audit tab reads the sidecar
96
+ * only. A handful of ignorable-marked v1.5.0-era record events remain in one
97
+ * historical log as inert, load-verified history; physically deleting them
98
+ * would require whole-log seq renumbering and is not worth the corruption
99
+ * risk.
100
+ */
101
+ /** The sidecar file name inside a session's persistence directory. */
102
+ const RECORDS_SIDECAR = "agent-approval.jsonl";
103
+ /**
104
+ * On-disk persistence for the judge settings (model override + timeout +
105
+ * rules). Lives under DSH_HOME (same resolution as the plugin's own README
106
+ * documents), outside any profile's node_modules so reinstalls and upgrades
107
+ * never touch it.
76
108
  */
77
109
  const DATA_DIR = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "agent-approval");
78
- const RECORDS_FILE = join(DATA_DIR, "records.jsonl");
79
110
  const CONFIG_FILE = join(DATA_DIR, "config.json");
80
111
 
112
+ /**
113
+ * v1.6.0: the TypeSafe Jev judge backend. Jev is a "System One" decision
114
+ * model (https://api.typesafe.ai/v1/systemone): it does not generate text —
115
+ * it answers typed questions (Choice / Score / Noul) over one `state` with
116
+ * calibrated probability distributions in ~70–500ms. That is exactly the
117
+ * approval-verdict shape, so instead of spawning a judge subagent through
118
+ * the harness model registry (Jev is not a chat route and cannot appear in
119
+ * `llm.listProviders()`), the Host half calls its HTTP API directly when the
120
+ * configured judge provider is the synthetic `typesafe` id. Fail-closed is
121
+ * preserved end to end: any transport fault, non-200, malformed answer, or a
122
+ * confidence below the configured gate resolves `unavailable` — never a
123
+ * grant, and (below the gate) not a recorded rejection either.
124
+ */
125
+ const JEV_PROVIDER = "typesafe";
126
+ const JEV_DEFAULT_MODEL = "jev-latest";
127
+ const JEV_DEFAULT_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
128
+ const JEV_DEFAULT_CONFIDENCE = 0.5;
129
+
130
+ /**
131
+ * The typed questions sent to Jev. Policy lives in the criteria descriptions
132
+ * (Jev reads instructions literally and injects domain knowledge only through
133
+ * state + criteria); the wording mirrors the subagent judge prompt's APPROVE
134
+ * conditions / REJECT list / 误杀治理 so both backends judge to the same
135
+ * standard. `decision` and `riskLevel` are Choices (discrete options +
136
+ * probabilities + confidence); `concreteRisk` is a Noul probe whose
137
+ * probability is folded into the synthesized audit rationale.
138
+ */
139
+ const JEV_QUESTIONS = {
140
+ decision: {
141
+ type: "choice",
142
+ instructions: "Should this one-time sandbox-escalation request be approved?",
143
+ criteria: {
144
+ approve:
145
+ "Yes, approve once. The operation is plausibly safe, non-destructive and reversible; it is clearly required by or consistent with the user's task; the stated reason honestly matches the actual arguments; and granting it once cannot leak secrets or cause irreversible system-wide changes. The project's own documented install/build/deploy steps that place the project's own files into the tool's own documented directories, and reading tool-owned config or logs needed for the task, count as task-scoped.",
146
+ reject:
147
+ "No, reject. The operation is destructive or irreversible (mass deletion, disk formatting, registry/service/system-wide changes), exfiltrates credentials or secrets, touches resources unrelated to the task, modifies the operating system or other applications' data, hides intent behind encoded or obfuscated content, the stated reason does not match the actual arguments, or there is a concrete, credible risk — something it would destroy, leak, or change beyond the user's task.",
148
+ },
149
+ },
150
+ riskLevel: {
151
+ type: "choice",
152
+ instructions: "How risky is the requested operation?",
153
+ criteria: {
154
+ low: "Routine and easily reversible: reading files, or writing within the project workspace that can be regenerated or undone.",
155
+ medium: "Awkward to undo or touches more than the immediate task outputs, but not destructive and not security-sensitive.",
156
+ high: "Destructive, irreversible, system-wide, or touching credentials, secrets, or other applications' data.",
157
+ },
158
+ },
159
+ concreteRisk: {
160
+ type: "noul",
161
+ instructions:
162
+ "Does this specific operation create a concrete, credible risk — destroying data, leaking secrets or credentials, or changing the operating system, other applications, or resources beyond the user's task? Answer false when the operation is task-scoped and reversible; vague unease or an unfamiliar command is NOT a risk.",
163
+ },
164
+ };
165
+
81
166
  /**
82
167
  * The structured verdict the judge subagent MUST produce. Constrained to the
83
168
  * JSON-Schema subset `assertObjectJsonSchema` enforces for subagent outputs
@@ -199,21 +284,32 @@ export class AgentApprovalService extends TypertRemoteService {
199
284
  async [Service.init]() {
200
285
  markRemoteMethod(this, "getState", "getState");
201
286
  markRemoteMethod(this, "setModel", "setModel");
287
+ markRemoteMethod(this, "setJevConfig", "setJevConfig");
202
288
  markRemoteMethod(this, "setApprovalTimeout", "setApprovalTimeout");
203
289
  markRemoteMethod(this, "toggle", "toggle");
204
290
  markRemoteMethod(this, "addRule", "addRule");
205
291
  markRemoteMethod(this, "removeRule", "removeRule");
206
- markRemoteMethod(this, "clearRecords", "clearRecords");
292
+ markRemoteMethod(this, "sessionRecords", "sessionRecords");
207
293
  markRemoteMethod(this, "directory", "directory");
208
294
 
209
295
  /** Judge model override; empty strings = use the harness default route. */
210
296
  this._model = { provider: "", model: "" };
297
+ /**
298
+ * TypeSafe Jev direct backend settings (used when `_model.provider` is
299
+ * the synthetic `typesafe` id). The API key lives in plaintext on this
300
+ * machine only (config.json, same trust domain as the rest of the
301
+ * settings); an empty key falls back to the TYPESAFE_API_KEY env var.
302
+ */
303
+ this._jev = {
304
+ apiKey: "",
305
+ endpoint: JEV_DEFAULT_ENDPOINT,
306
+ model: JEV_DEFAULT_MODEL,
307
+ confidence: JEV_DEFAULT_CONFIDENCE,
308
+ };
211
309
  /** Judge timeout in ms (clamped); a timeout resolves fail-closed. */
212
310
  this._timeoutMs = DEFAULT_TIMEOUT_MS;
213
311
  /** sessionId -> { prevSandbox?: string, prevApproval?: string } */
214
312
  this._enabled = new Map();
215
- /** Audit records, oldest first, capped at MAX_RECORDS. */
216
- this._records = [];
217
313
  /**
218
314
  * Deterministic rules judged BEFORE the model (persisted in config.json):
219
315
  * [{ id, effect: "allow"|"deny", tool, match, note, createdAt }]. A hit
@@ -571,11 +667,16 @@ export class AgentApprovalService extends TypertRemoteService {
571
667
 
572
668
  // ---- audit ----------------------------------------------------------------
573
669
 
574
- /** Coerce one entry to the strict wire shape (typert result schema). */
575
- _recordShape(entry) {
670
+ /**
671
+ * Coerce one entry to the strict wire shape (typert result schema). The
672
+ * session column is filled by the reader — the sidecar lives inside the
673
+ * session's own directory, so the id is implied but still stamped into
674
+ * every line to keep the file self-describing.
675
+ */
676
+ _recordShape(sessionId, entry) {
576
677
  return {
577
678
  at: String(entry.at),
578
- sessionId: String(entry.sessionId),
679
+ sessionId: String(sessionId),
579
680
  toolName: String(entry.toolName),
580
681
  reason: String(entry.reason),
581
682
  args: String(entry.args),
@@ -588,35 +689,83 @@ export class AgentApprovalService extends TypertRemoteService {
588
689
  };
589
690
  }
590
691
 
591
- /** Append one audit record (coerced), cap the ring, persist as JSONL. */
592
- _record(entry) {
593
- const shape = this._recordShape(entry);
594
- this._records.push(shape);
595
- if (this._records.length > MAX_RECORDS) {
596
- this._records.splice(0, this._records.length - MAX_RECORDS);
692
+ /**
693
+ * Resolve the audit sidecar for one session: `agent-approval.jsonl` inside
694
+ * the session's persistence directory (same directory as the session's own
695
+ * durable log, via `sessionPersistence.locate(header)` — a pure path
696
+ * resolution that also works for live sessions). Falls back to a
697
+ * plugin-owned per-session file under DSH_HOME when the seam or the
698
+ * location is unavailable; the fallback keeps restart-safety at the cost
699
+ * of not being cleaned up when the session is deleted.
700
+ */
701
+ async _recordsFileOf(session) {
702
+ const persistence = this.ctx.get("sessionPersistence");
703
+ if (persistence !== undefined && typeof persistence.locate === "function") {
704
+ try {
705
+ const loc = persistence.locate(session.header);
706
+ if (loc && typeof loc.path === "string" && loc.path !== "") {
707
+ return join(dirname(loc.path), RECORDS_SIDECAR);
708
+ }
709
+ } catch (e) {
710
+ /* fall through to the plugin-owned fallback */
711
+ }
597
712
  }
598
- mkdir(DATA_DIR, { recursive: true })
599
- .then(() => appendFile(RECORDS_FILE, JSON.stringify(shape) + "\n", "utf8"))
600
- .catch(() => {
601
- /* persistence is best-effort; the in-memory ring still works */
602
- });
713
+ return join(DATA_DIR, "records", `${String(session.id)}.jsonl`);
714
+ }
715
+
716
+ /**
717
+ * Append one audit record to the session's SIDECAR file (see
718
+ * `_recordsFileOf`). Appending must never break the approval flow it
719
+ * audits: fire-and-forget with every failure swallowed.
720
+ */
721
+ _record(session, entry) {
722
+ const shape = this._recordShape(session.id, entry);
723
+ void (async () => {
724
+ try {
725
+ const file = await this._recordsFileOf(session);
726
+ await mkdir(dirname(file), { recursive: true });
727
+ await appendFile(file, JSON.stringify(shape) + "\n", "utf8");
728
+ } catch (e) {
729
+ /* audit is best-effort; the approval outcome still stands */
730
+ }
731
+ })();
603
732
  }
604
733
 
605
- /** Rewrite the whole records file from the in-memory ring (clear/compact). */
606
- async _rewriteRecordsFile() {
734
+ /**
735
+ * Fold one session's audit records (chronological by `at`). The sidecar
736
+ * file is the ONLY source — the durable event log is never consulted
737
+ * (v1.5.2: zero custom data read from or written to session.jsonl.zstd).
738
+ * Never throws.
739
+ */
740
+ async _recordsOf(session) {
741
+ const out = [];
607
742
  try {
608
- await mkdir(DATA_DIR, { recursive: true });
609
- const body = this._records.map((r) => JSON.stringify(r)).join("\n");
610
- await writeFile(RECORDS_FILE, body === "" ? "" : body + "\n", "utf8");
743
+ const file = await this._recordsFileOf(session);
744
+ const text = await readFile(file, "utf8");
745
+ for (const raw of text.split("\n")) {
746
+ const line = raw.trim();
747
+ if (line === "") continue;
748
+ try {
749
+ const parsed = JSON.parse(line);
750
+ if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
751
+ out.push(this._recordShape(session.id, parsed));
752
+ }
753
+ } catch (e) {
754
+ /* skip the corrupt line */
755
+ }
756
+ }
611
757
  } catch (e) {
612
- /* best-effort */
758
+ /* no sidecar yet */
613
759
  }
760
+ out.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
761
+ return out;
614
762
  }
615
763
 
616
764
  /** Persist the judge settings (model override + timeout + rules) to config.json. */
617
765
  _persistConfig() {
618
766
  const body = JSON.stringify({
619
767
  model: { provider: this._model.provider, model: this._model.model },
768
+ jev: this._jevShape(),
620
769
  timeoutMs: this._timeoutMs,
621
770
  rules: this._rules,
622
771
  });
@@ -628,9 +777,9 @@ export class AgentApprovalService extends TypertRemoteService {
628
777
  }
629
778
 
630
779
  /**
631
- * Load persisted settings + records at startup. Corrupt files/lines are
632
- * skipped individually; the records file is compacted back down to the ring
633
- * size so it cannot grow without bound. Never throws.
780
+ * Load persisted judge settings at startup (audit records need no loading —
781
+ * they live in the session logs and are folded per session on demand).
782
+ * Corrupt config is skipped; never throws.
634
783
  */
635
784
  async _loadPersisted() {
636
785
  try {
@@ -643,6 +792,18 @@ export class AgentApprovalService extends TypertRemoteService {
643
792
  ) {
644
793
  this._model = { provider: cfg.model.provider, model: cfg.model.model };
645
794
  }
795
+ if (cfg.jev && typeof cfg.jev === "object") {
796
+ if (typeof cfg.jev.apiKey === "string") this._jev.apiKey = cfg.jev.apiKey;
797
+ if (typeof cfg.jev.endpoint === "string" && cfg.jev.endpoint !== "") {
798
+ this._jev.endpoint = cfg.jev.endpoint;
799
+ }
800
+ if (typeof cfg.jev.model === "string" && cfg.jev.model !== "") {
801
+ this._jev.model = cfg.jev.model;
802
+ }
803
+ if (typeof cfg.jev.confidence === "number" && Number.isFinite(cfg.jev.confidence)) {
804
+ this._jev.confidence = Math.min(0.99, Math.max(0.01, cfg.jev.confidence));
805
+ }
806
+ }
646
807
  if (typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs)) {
647
808
  this._timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.floor(cfg.timeoutMs)));
648
809
  }
@@ -671,28 +832,6 @@ export class AgentApprovalService extends TypertRemoteService {
671
832
  } catch (e) {
672
833
  /* first run or unreadable config — keep the defaults */
673
834
  }
674
- try {
675
- const text = await readFile(RECORDS_FILE, "utf8");
676
- const lines = text.split("\n");
677
- const kept = [];
678
- for (let i = lines.length - 1; i >= 0 && kept.length < MAX_RECORDS; i--) {
679
- const line = lines[i].trim();
680
- if (line === "") continue;
681
- try {
682
- const parsed = JSON.parse(line);
683
- if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
684
- kept.push(this._recordShape(parsed));
685
- }
686
- } catch (e) {
687
- /* skip the corrupt line */
688
- }
689
- }
690
- kept.reverse();
691
- this._records = kept;
692
- if (lines.length > kept.length) await this._rewriteRecordsFile();
693
- } catch (e) {
694
- /* no records file yet */
695
- }
696
835
  }
697
836
 
698
837
  // ---- the claimer ----------------------------------------------------------
@@ -811,9 +950,8 @@ export class AgentApprovalService extends TypertRemoteService {
811
950
  // A listener throw would make the whole waterfall fail closed with
812
951
  // 'unavailable' anyway; record what we can and resolve the same way.
813
952
  try {
814
- this._record({
953
+ this._record(session, {
815
954
  at: new Date().toISOString(),
816
- sessionId: shortId(session.id),
817
955
  toolName: String(req.toolName),
818
956
  reason: trunc(req.reason, 300),
819
957
  args: "",
@@ -839,6 +977,10 @@ export class AgentApprovalService extends TypertRemoteService {
839
977
  * records display — "p/m" = selected, "default(p/m)" = harness default.
840
978
  */
841
979
  _judgeRoute() {
980
+ if (this._model.provider === JEV_PROVIDER) {
981
+ const model = this._jevEffective().model;
982
+ return { provider: JEV_PROVIDER, model: model, label: "jev(" + model + ")" };
983
+ }
842
984
  if (this._model.provider !== "" && this._model.model !== "") {
843
985
  return {
844
986
  provider: this._model.provider,
@@ -870,7 +1012,6 @@ export class AgentApprovalService extends TypertRemoteService {
870
1012
  const toolName = String(req.toolName);
871
1013
  const base = {
872
1014
  at: startedAt,
873
- sessionId: shortId(session.id),
874
1015
  toolName: toolName,
875
1016
  reason: trunc(req.reason, 300),
876
1017
  args: trunc(argsRaw, 2000),
@@ -888,10 +1029,10 @@ export class AgentApprovalService extends TypertRemoteService {
888
1029
  (rule.note !== "" ? " — " + rule.note : "");
889
1030
  base.durationMs = Date.now() - t0;
890
1031
  if (rule.effect === "deny") {
891
- this._record({ ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
1032
+ this._record(session, { ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
892
1033
  return "rejected";
893
1034
  }
894
- this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
1035
+ this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
895
1036
  return "allowed-once";
896
1037
  }
897
1038
 
@@ -901,11 +1042,17 @@ export class AgentApprovalService extends TypertRemoteService {
901
1042
  const trusted = this._trusted.get(session.id);
902
1043
  if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
903
1044
  base.durationMs = Date.now() - t0;
904
- this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
1045
+ this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
905
1046
  return "allowed-once";
906
1047
  }
907
1048
 
908
- // 3. The model judge.
1049
+ // 3. The judge. The TypeSafe Jev backend is a direct HTTP call (no
1050
+ // subagent, no harness model route); anything else spawns the judge
1051
+ // child through the `spawn` provider as before.
1052
+ if (this._model.provider === JEV_PROVIDER) {
1053
+ return this._judgeWithJev(session, req, argsRaw, base, trustKey);
1054
+ }
1055
+
909
1056
  const route = this._judgeRoute();
910
1057
 
911
1058
  let run;
@@ -923,7 +1070,7 @@ export class AgentApprovalService extends TypertRemoteService {
923
1070
  persona: APPROVER_PERSONA,
924
1071
  });
925
1072
  } catch (error) {
926
- this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
1073
+ this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
927
1074
  return "unavailable";
928
1075
  }
929
1076
  base.childSessionId = shortId(run.id);
@@ -960,7 +1107,7 @@ export class AgentApprovalService extends TypertRemoteService {
960
1107
  (verdict.decision === "approve" || verdict.decision === "reject")
961
1108
  ) {
962
1109
  const approved = verdict.decision === "approve";
963
- this._record({
1110
+ this._record(session, {
964
1111
  ...base,
965
1112
  outcome: approved ? "allowed-once" : "rejected",
966
1113
  riskLevel: String(verdict.riskLevel || "-"),
@@ -979,7 +1126,7 @@ export class AgentApprovalService extends TypertRemoteService {
979
1126
  }
980
1127
  return approved ? "allowed-once" : "rejected";
981
1128
  }
982
- this._record({
1129
+ this._record(session, {
983
1130
  ...base,
984
1131
  outcome: "unavailable",
985
1132
  riskLevel: "-",
@@ -990,11 +1137,11 @@ export class AgentApprovalService extends TypertRemoteService {
990
1137
  return "unavailable";
991
1138
  }
992
1139
  if (winner.kind === "aborted") {
993
- this._record({ ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
1140
+ this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
994
1141
  return "cancelled";
995
1142
  }
996
1143
  if (winner.kind === "timeout") {
997
- this._record({
1144
+ this._record(session, {
998
1145
  ...base,
999
1146
  outcome: "unavailable",
1000
1147
  riskLevel: "-",
@@ -1003,10 +1150,280 @@ export class AgentApprovalService extends TypertRemoteService {
1003
1150
  });
1004
1151
  return "unavailable";
1005
1152
  }
1006
- this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
1153
+ this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
1154
+ return "unavailable";
1155
+ }
1156
+
1157
+ // ---- the TypeSafe Jev direct backend ---------------------------------------
1158
+
1159
+ /**
1160
+ * Effective Jev settings with env fallback and clamping applied. The key
1161
+ * may come from config.json or the TYPESAFE_API_KEY environment variable;
1162
+ * an absent key keeps the backend selected but every judgment resolves
1163
+ * `unavailable` (fail closed) until one is configured.
1164
+ */
1165
+ _jevEffective() {
1166
+ const key = String(this._jev.apiKey || process.env.TYPESAFE_API_KEY || "").trim();
1167
+ const endpoint = String(this._jev.endpoint || "").trim() || JEV_DEFAULT_ENDPOINT;
1168
+ const model = String(this._jev.model || "").trim() || JEV_DEFAULT_MODEL;
1169
+ let confidence = Number(this._jev.confidence);
1170
+ if (!Number.isFinite(confidence)) confidence = JEV_DEFAULT_CONFIDENCE;
1171
+ confidence = Math.min(0.99, Math.max(0.01, confidence));
1172
+ return { key: key, endpoint: endpoint, model: model, confidence: confidence };
1173
+ }
1174
+
1175
+ /** Owned plain copy of the Jev settings for the wire and config.json. */
1176
+ _jevShape() {
1177
+ return {
1178
+ apiKey: String(this._jev.apiKey || ""),
1179
+ endpoint: String(this._jev.endpoint || JEV_DEFAULT_ENDPOINT),
1180
+ model: String(this._jev.model || JEV_DEFAULT_MODEL),
1181
+ confidence: Number(this._jev.confidence) || JEV_DEFAULT_CONFIDENCE,
1182
+ };
1183
+ }
1184
+
1185
+ /**
1186
+ * The `state` sent to Jev: the same ground truth the subagent judge sees
1187
+ * (workspace, exact arguments, stated reason, first + recent genuine user
1188
+ * messages), as a named object — the shape TypeSafe recommends. All values
1189
+ * are pre-truncated strings so the 32K state budget is respected.
1190
+ */
1191
+ _jevStateOf(session, req, argsRaw) {
1192
+ let cwd = "";
1193
+ try {
1194
+ if (session.header && typeof session.header.cwd === "string") cwd = session.header.cwd;
1195
+ } catch (e) {
1196
+ /* header access is best-effort */
1197
+ }
1198
+ const task = this._recentUserContext(session);
1199
+ return {
1200
+ workspace: cwd || "(unknown)",
1201
+ tool: String(req.toolName),
1202
+ statedReason:
1203
+ typeof req.reason === "string" && req.reason !== "" ? trunc(req.reason, 300) : "(none)",
1204
+ toolArguments: argsRaw === undefined ? "(not available)" : trunc(argsRaw, 4000) || "(empty)",
1205
+ firstUserMessage: task.first !== "" ? task.first : "(no user messages available)",
1206
+ recentUserMessages: task.recent,
1207
+ };
1208
+ }
1209
+
1210
+ /**
1211
+ * Judge one escalation through the Jev HTTP API (state + typed questions →
1212
+ * calibrated probability distributions). Mirrors `_judge`'s spawn-path
1213
+ * contract exactly — rules and the session trust cache have already run —
1214
+ * and every abnormal shape resolves fail-closed:
1215
+ * - no API key / transport fault / non-200 / malformed answer → `unavailable`
1216
+ * - request cancelled mid-flight → `cancelled`
1217
+ * - overall timeout (the same `this._timeoutMs` budget) → `unavailable`
1218
+ * - confidence below the configured gate → `unavailable` (the model is
1219
+ * not sure enough to decide: never a grant, and not a recorded
1220
+ * rejection either — the v1.4.0 误杀治理 applies symmetrically)
1221
+ * Jev does not generate text, so the audit rationale is synthesized from
1222
+ * the returned distributions; the served model version (`body.model`,
1223
+ * which resolves aliases like jev-latest) is what the audit displays.
1224
+ */
1225
+ async _judgeWithJev(session, req, argsRaw, base, trustKey) {
1226
+ const cfg = this._jevEffective();
1227
+ if (cfg.key === "") {
1228
+ this._record(session, {
1229
+ ...base,
1230
+ outcome: "unavailable",
1231
+ riskLevel: "-",
1232
+ model: "jev(" + cfg.model + ")",
1233
+ rationale: "Jev backend selected but no API key configured (Settings → Agent 审批, or the TYPESAFE_API_KEY environment variable)",
1234
+ });
1235
+ return "unavailable";
1236
+ }
1237
+ if (typeof fetch !== "function") {
1238
+ this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "fetch is unavailable in this runtime" });
1239
+ return "unavailable";
1240
+ }
1241
+
1242
+ const startedAt = Date.now();
1243
+ const controller = new AbortController();
1244
+ const signal = req.signal;
1245
+ const onAbort = () => controller.abort();
1246
+ if (signal && typeof signal.addEventListener === "function") {
1247
+ signal.addEventListener("abort", onAbort, { once: true });
1248
+ }
1249
+
1250
+ let winner;
1251
+ try {
1252
+ const state = this._jevStateOf(session, req, argsRaw);
1253
+ winner = await Promise.race([
1254
+ this._jevRequest(cfg, state, controller.signal)
1255
+ .then((body) => ({ kind: "result", body: body }))
1256
+ .catch((error) => ({
1257
+ kind: "fault",
1258
+ error: error,
1259
+ aborted: error && error.name === "AbortError",
1260
+ })),
1261
+ (signal
1262
+ ? new Promise((resolve) => {
1263
+ if (signal.aborted) {
1264
+ resolve(true);
1265
+ return;
1266
+ }
1267
+ signal.addEventListener("abort", () => resolve(true), { once: true });
1268
+ })
1269
+ : Promise.resolve(false)
1270
+ ).then((v) => ({ kind: "aborted", aborted: v })),
1271
+ this.ctx.timeout(this._timeoutMs).then(() => ({ kind: "timeout" })),
1272
+ ]);
1273
+ } finally {
1274
+ if (signal && typeof signal.removeEventListener === "function") {
1275
+ signal.removeEventListener("abort", onAbort);
1276
+ }
1277
+ // Whether we lost the race to timeout/cancel or the request already
1278
+ // settled, closing the transport is always safe.
1279
+ try {
1280
+ controller.abort();
1281
+ } catch (e) {
1282
+ /* controller abort never blocks the outcome */
1283
+ }
1284
+ }
1285
+ const durationMs = Date.now() - startedAt;
1286
+
1287
+ if (winner.kind === "result") {
1288
+ return this._jevVerdict(session, winner.body, cfg, base, trustKey, durationMs);
1289
+ }
1290
+ if (winner.kind === "aborted") {
1291
+ this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "request cancelled while Jev was judging" });
1292
+ return "cancelled";
1293
+ }
1294
+ if (winner.kind === "timeout") {
1295
+ this._record(session, {
1296
+ ...base,
1297
+ outcome: "unavailable",
1298
+ riskLevel: "-",
1299
+ model: "jev(" + cfg.model + ")",
1300
+ rationale: "Jev request timed out after " + String(this._timeoutMs) + "ms (fail closed)",
1301
+ });
1302
+ return "unavailable";
1303
+ }
1304
+ if (winner.aborted) {
1305
+ this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "request cancelled while Jev was judging" });
1306
+ return "cancelled";
1307
+ }
1308
+ this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "Jev request failed: " + errText(winner.error) });
1007
1309
  return "unavailable";
1008
1310
  }
1009
1311
 
1312
+ /** The single POST to the System One endpoint; resolves the parsed body. */
1313
+ async _jevRequest(cfg, state, abortSignal) {
1314
+ const response = await fetch(cfg.endpoint, {
1315
+ method: "POST",
1316
+ headers: {
1317
+ "Authorization": "Bearer " + cfg.key,
1318
+ "Content-Type": "application/json",
1319
+ },
1320
+ body: JSON.stringify({
1321
+ state: state,
1322
+ model: cfg.model,
1323
+ questions: JEV_QUESTIONS,
1324
+ }),
1325
+ signal: abortSignal,
1326
+ });
1327
+ if (!response.ok) {
1328
+ let detail = "";
1329
+ try {
1330
+ detail = trunc(String(await response.text()), 200);
1331
+ } catch (e) {
1332
+ /* body read is best-effort */
1333
+ }
1334
+ throw new Error("HTTP " + String(response.status) + (detail !== "" ? " " + detail : ""));
1335
+ }
1336
+ const body = await response.json();
1337
+ if (!body || typeof body !== "object") throw new Error("response body is not an object");
1338
+ return body;
1339
+ }
1340
+
1341
+ /**
1342
+ * Map a Jev response to the same outcomes the subagent path produces.
1343
+ * Returns the waterfall outcome string; records the audit line itself.
1344
+ */
1345
+ _jevVerdict(session, body, cfg, base, trustKey, durationMs) {
1346
+ const served = typeof body.model === "string" && body.model !== "" ? body.model : cfg.model;
1347
+ const label = "jev(" + served + ")";
1348
+ const answers = body.answers && typeof body.answers === "object" ? body.answers : {};
1349
+ const decision = answers.decision && typeof answers.decision === "object" ? answers.decision : undefined;
1350
+ const risk = answers.riskLevel && typeof answers.riskLevel === "object" ? answers.riskLevel : undefined;
1351
+ const probe = answers.concreteRisk && typeof answers.concreteRisk === "object" ? answers.concreteRisk : undefined;
1352
+
1353
+ const choice = decision && (decision.choice === "approve" || decision.choice === "reject") ? decision.choice : undefined;
1354
+ const confidence = decision ? Number(decision.confidence) : NaN;
1355
+ const probabilities = decision && decision.probabilities && typeof decision.probabilities === "object" ? decision.probabilities : {};
1356
+ const riskChoice =
1357
+ risk && (risk.choice === "low" || risk.choice === "medium" || risk.choice === "high") ? risk.choice : undefined;
1358
+ const probeNoul = probe ? Number(probe.noul) : NaN;
1359
+
1360
+ // Any missing or out-of-shape answer is fail-closed, not guessed.
1361
+ if (
1362
+ choice === undefined ||
1363
+ !Number.isFinite(confidence) ||
1364
+ confidence < 0 ||
1365
+ confidence > 1 ||
1366
+ riskChoice === undefined ||
1367
+ !Number.isFinite(probeNoul)
1368
+ ) {
1369
+ this._record(session, {
1370
+ ...base,
1371
+ durationMs: durationMs,
1372
+ outcome: "unavailable",
1373
+ riskLevel: "-",
1374
+ model: label,
1375
+ rationale: "Jev returned no valid verdict shape (decision/riskLevel/concreteRisk incomplete)",
1376
+ });
1377
+ return "unavailable";
1378
+ }
1379
+
1380
+ // Confidence gate: below the threshold the model is not sure enough to
1381
+ // decide at all — never a grant, never a recorded rejection.
1382
+ if (confidence < cfg.confidence) {
1383
+ this._record(session, {
1384
+ ...base,
1385
+ durationMs: durationMs,
1386
+ outcome: "unavailable",
1387
+ riskLevel: riskChoice,
1388
+ model: label,
1389
+ rationale:
1390
+ "Jev confidence " + confidence.toFixed(2) + " is below the gate " + cfg.confidence.toFixed(2) + " (decision draft: " + choice + ") — fail closed",
1391
+ });
1392
+ return "unavailable";
1393
+ }
1394
+
1395
+ const pApprove = Number(probabilities.approve);
1396
+ const pReject = Number(probabilities.reject);
1397
+ const rationale =
1398
+ "Jev 决策=" + choice +
1399
+ "(置信度 " + confidence.toFixed(2) +
1400
+ (Number.isFinite(pApprove) && Number.isFinite(pReject)
1401
+ ? ",p approve/reject " + pApprove.toFixed(2) + "/" + pReject.toFixed(2)
1402
+ : "") +
1403
+ ");风险=" + riskChoice +
1404
+ ";具体风险概率=" + probeNoul.toFixed(2) +
1405
+ "。Jev 为结构化决策模型,不生成文字,本理由由概率分布合成。";
1406
+
1407
+ base.durationMs = durationMs;
1408
+ const approved = choice === "approve";
1409
+ this._record(session, {
1410
+ ...base,
1411
+ outcome: approved ? "allowed-once" : "rejected",
1412
+ riskLevel: riskChoice,
1413
+ model: label,
1414
+ rationale: trunc(rationale, 600),
1415
+ });
1416
+ if (approved && trustKey !== undefined) {
1417
+ let set = this._trusted.get(session.id);
1418
+ if (set === undefined) {
1419
+ set = new Set();
1420
+ this._trusted.set(session.id, set);
1421
+ }
1422
+ set.add(trustKey);
1423
+ }
1424
+ return approved ? "allowed-once" : "rejected";
1425
+ }
1426
+
1010
1427
  // ---- Remote API ------------------------------------------------------------
1011
1428
 
1012
1429
  /**
@@ -1048,10 +1465,10 @@ export class AgentApprovalService extends TypertRemoteService {
1048
1465
  ok: true,
1049
1466
  value: {
1050
1467
  model: { provider: this._model.provider, model: this._model.model },
1468
+ jev: this._jevShape(),
1051
1469
  timeoutMs: this._timeoutMs,
1052
1470
  enabledSessions: this._sessionInfos(),
1053
1471
  rules: this._rulesSnapshot(),
1054
- records: this._records.slice(-50).reverse(),
1055
1472
  },
1056
1473
  };
1057
1474
  }
@@ -1063,12 +1480,35 @@ export class AgentApprovalService extends TypertRemoteService {
1063
1480
  async setModel(request) {
1064
1481
  const provider = request && typeof request.provider === "string" ? request.provider : "";
1065
1482
  const model = request && typeof request.model === "string" ? request.model : "";
1066
- this._model =
1067
- provider !== "" && model !== "" ? { provider, model } : { provider: "", model: "" };
1483
+ if (provider === JEV_PROVIDER) {
1484
+ // The Jev backend ignores the harness route table; an unset model just
1485
+ // means the latest alias.
1486
+ this._model = { provider: JEV_PROVIDER, model: model !== "" ? model : JEV_DEFAULT_MODEL };
1487
+ } else {
1488
+ this._model =
1489
+ provider !== "" && model !== "" ? { provider, model } : { provider: "", model: "" };
1490
+ }
1068
1491
  this._persistConfig();
1069
1492
  return { ok: true, value: { model: { provider: this._model.provider, model: this._model.model } } };
1070
1493
  }
1071
1494
 
1495
+ /**
1496
+ * Set the TypeSafe Jev backend settings (only provided fields change).
1497
+ * `confidence` is the gate below which Jev's answer is not trusted and the
1498
+ * outcome resolves fail-closed; clamped to [0.01, 0.99]. Persisted.
1499
+ */
1500
+ async setJevConfig(request) {
1501
+ const r = request && typeof request === "object" ? request : {};
1502
+ if (typeof r.apiKey === "string") this._jev.apiKey = r.apiKey.trim();
1503
+ if (typeof r.endpoint === "string") this._jev.endpoint = r.endpoint.trim();
1504
+ if (typeof r.model === "string") this._jev.model = r.model.trim();
1505
+ if (typeof r.confidence === "number" && Number.isFinite(r.confidence)) {
1506
+ this._jev.confidence = Math.min(0.99, Math.max(0.01, r.confidence));
1507
+ }
1508
+ this._persistConfig();
1509
+ return { ok: true, value: { jev: this._jevShape() } };
1510
+ }
1511
+
1072
1512
  /** Set the judge timeout (clamped to [MIN, MAX] milliseconds). Persisted. */
1073
1513
  async setApprovalTimeout(request) {
1074
1514
  const raw = request && typeof request.timeoutMs === "number" ? request.timeoutMs : 0;
@@ -1136,11 +1576,34 @@ export class AgentApprovalService extends TypertRemoteService {
1136
1576
  return { ok: true, value: { rules: this._rulesSnapshot() } };
1137
1577
  }
1138
1578
 
1139
- /** Clear the audit records (memory + persisted file). */
1140
- async clearRecords() {
1141
- this._records = [];
1142
- await this._rewriteRecordsFile();
1143
- return { ok: true, value: { cleared: true } };
1579
+ /**
1580
+ * Fold ONE session's audit records out of its sidecar storage (see
1581
+ * `_recordsFileOf` / `_recordsOf`). Powers the conversation window's「审批」
1582
+ * tab — the records are requested per session and rendered next to the
1583
+ * 轨迹 tab, exactly where they were produced. The session must be live (it
1584
+ * always is when its conversation window is open). Also reports whether
1585
+ * the mode is currently enabled for the session so the tab can show the
1586
+ * state.
1587
+ */
1588
+ async sessionRecords(request) {
1589
+ const sessionId = request && typeof request.sessionId === "string" ? request.sessionId : "";
1590
+ if (sessionId === "") {
1591
+ return { ok: false, error: { code: "invalid-session", message: "sessionId is required" } };
1592
+ }
1593
+ const agent = this.ctx.agents.get(sessionId);
1594
+ if (agent === undefined) {
1595
+ return {
1596
+ ok: false,
1597
+ error: { code: "session-not-live", message: "that session is not live right now" },
1598
+ };
1599
+ }
1600
+ return {
1601
+ ok: true,
1602
+ value: {
1603
+ records: await this._recordsOf(agent.session),
1604
+ enabled: this._enabled.has(sessionId),
1605
+ },
1606
+ };
1144
1607
  }
1145
1608
 
1146
1609
  /**