@mrweicodes/dsh-permgate 1.3.11 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +211 -211
  2. package/client.js +273 -36
  3. package/index.js +1470 -395
  4. package/package.json +12 -2
package/client.js CHANGED
@@ -261,6 +261,7 @@ window.__ModuleLoader__.load({
261
261
  'permgate:reload': ['POST', '/permgate/reload'],
262
262
  'permgate:open-config': ['POST', '/permgate/open-config'],
263
263
  'permgate:open-file': ['POST', '/permgate/open-file'],
264
+ 'permgate:set-fallback': ['POST', '/permgate/set-fallback'],
264
265
  };
265
266
  function call(method, args) {
266
267
  const entry = ROUTES[method] || ['GET', '/permgate/status'];
@@ -659,17 +660,31 @@ window.__ModuleLoader__.load({
659
660
  };
660
661
  }
661
662
 
662
- const CATS = ['directory', 'command', 'read', 'edit', 'subagent', 'doomloop'];
663
- const EXC_CATS = ['directory', 'command', 'read', 'edit'];
663
+ // 分类清单默认值:以宿主下发为准(见 applyStatusLists),避免双端各存一份而漂移
664
+ let CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo', 'subagent', 'doomloop'];
665
+ let EXC_CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo'];
666
+ function applyStatusLists(s) {
667
+ if (s && Array.isArray(s.cats) && s.cats.length) CATS = s.cats;
668
+ if (s && Array.isArray(s.excCats) && s.excCats.length) EXC_CATS = s.excCats;
669
+ if (s && Array.isArray(s.modes) && s.modes.length) MODES = s.modes;
670
+ if (s && Array.isArray(s.allModes) && s.allModes.length) ALL_MODES = s.allModes;
671
+ if (s && Array.isArray(s.quickPreset) && s.quickPreset.length) QUICK_PRESET = s.quickPreset;
672
+ if (s && s.quickDefaults && typeof s.quickDefaults === 'object') QUICK_DEFAULTS = s.quickDefaults;
673
+ }
664
674
  // 例外列表超过该数量默认折叠(展开/折叠按钮在标题行右侧)
665
675
  const EXC_COLLAPSE_THRESHOLD = 6;
666
676
  // 文件对比缓存:按审批 id 缓存 /permgate/file-diff 结果(弹窗详情与右侧抽屉共用)
667
677
  const diffCache = new Map();
668
678
  const diffFetching = new Set();
669
- const ALL_MODES = ['ask', 'allow', 'deny', 'inherit'];
670
- const MODES = ['ask', 'allow', 'deny'];
679
+ let ALL_MODES = ['ask', 'allow', 'deny', 'inherit'];
680
+ let MODES = ['ask', 'allow', 'deny'];
671
681
  const MODE_COLORS = { ask: '#e65100', allow: '#2e7d32', deny: '#c62828', inherit: '#888' };
672
- const QUICK_PRESET = ['web_search', 'skill', 'grep', 'glob', 'web_fetch'];
682
+ // 快捷工具预设清单与默认动作都由宿主下发(permgate:status quickPreset/quickDefaults),
683
+ // 与 CATS/MODES 同口径,避免宿主与浏览器各存一份清单、新增工具时漂移;
684
+ // 未显式配置的工具按「项目键 → 全局键 → 预设默认 → 会话兜底」显示,与服务端 quickAction 同链。
685
+ let QUICK_PRESET = [];
686
+ let QUICK_DEFAULTS = {};
687
+ let QUICK_FALLBACK = 'ask';
673
688
 
674
689
  // ── 中英文适配:字典 + locale 绑定(跟随 dsh 语言设置自动切换)──────────
675
690
  let LC = null; // apply 时注入的 ctx
@@ -694,6 +709,10 @@ window.__ModuleLoader__.load({
694
709
  'app.diffMore': '其余 {n} 行',
695
710
  'app.diffLoading': '加载中…',
696
711
  'app.diffErr': '无法生成对比',
712
+ 'app.imageTag': '图片',
713
+ 'app.imageTooLarge': '图片过大,未生成缩略图',
714
+ 'app.imageSizeUnknown': '无法确认图片尺寸,未生成缩略图',
715
+ 'app.imageNoPreview': '暂无可用的缩略图',
697
716
  'app.uiErr': '界面渲染出错',
698
717
  'app.eolNote': '行尾已按 LF 归一化匹配(磁盘为 CRLF/CR 而参数为 LF):预览仅为意图展示,实际编辑可能因行尾不一致失败',
699
718
  'app.diffGapShow': '显示 {n} 行未更改',
@@ -718,13 +737,17 @@ window.__ModuleLoader__.load({
718
737
  'cat.directory': '目录访问(工作区外)',
719
738
  'cat.command': '执行命令',
720
739
  'cat.read': '读取文件',
740
+ 'cat.image': '读取图片',
721
741
  'cat.edit': '编辑文件',
742
+ 'cat.undo': '撤销操作(恢复上次编辑前的内容)',
722
743
  'cat.subagent': '启动子代理',
723
744
  'cat.doomloop': '重复操作(Doom Loop)',
724
745
  'catS.directory': '目录',
725
746
  'catS.command': '命令',
726
747
  'catS.read': '读取',
748
+ 'catS.image': '图片',
727
749
  'catS.edit': '编辑',
750
+ 'catS.undo': '撤销',
728
751
  'catS.subagent': '子代理',
729
752
  'catS.doomloop': '循环',
730
753
  'mode.ask': '询问',
@@ -758,11 +781,10 @@ window.__ModuleLoader__.load({
758
781
  'sandbox.desc.fa': '文件操作不受工作区限制',
759
782
  'sandbox.desc.inherit': '跟随全局设置(当前全局:{g})',
760
783
  'panel.quick': '快捷工具(其他工具快速设置)',
761
- 'panel.quickHint': '适用于网页搜索、技能加载、grep/glob 等未归类的工具;改动即时生效。',
784
+ 'panel.quickHint': '无文件/命令语义的工具,按工具名设定默认动作;不在清单里的工具走兜底策略,改动即时生效。',
762
785
  'panel.quickAdd': '新增工具名(如 todo_write,支持通配)',
763
- 'panel.quickAddBtn': '添加(默认允许)',
786
+ 'panel.quickAddBtn': '添加',
764
787
  'panel.rules': '自定义规则(通用匹配)',
765
- 'panel.rulesHint': '优先级:自定义规则 > 分类例外 > 分类默认/快捷工具。',
766
788
  'panel.actionDeny': 'deny 拒绝',
767
789
  'panel.actionAsk': 'ask 审批',
768
790
  'panel.actionAllow': 'allow 放行',
@@ -784,12 +806,52 @@ window.__ModuleLoader__.load({
784
806
  'panel.savedToProject': '已保存到当前项目',
785
807
  'panel.all': '(全部)',
786
808
  'panel.sandboxSaved': '底层沙箱已保存({t}):{v}',
809
+ 'panel.fallbackSaved': '兜底策略已保存({t}):{v}',
810
+ 'panel.fallback': '兜底策略(未匹配任何规则)',
811
+ 'panel.fallbackHint': '以上各分类之外、且不在快捷工具清单里的调用(如 mcp__*、cordis_run)如何处理。默认「询问」:每个未匹配的调用都会弹出审批。',
787
812
  'panel.needValue': '请先输入例外匹配',
813
+ 'panel.excRemaining': '该路径仍有 {n} 条同值例外,可继续逐条删除',
814
+ 'panel.delFailed': '删除失败',
788
815
  'panel.needTool': '请输入工具名',
789
816
  'panel.excCount': '例外({n})',
790
817
  'panel.excCmdHint': ':匹配命令子串/glob,优先于分类默认',
791
818
  'panel.excPathHint': ':路径 glob,优先于分类默认',
792
819
  'panel.excNone': '无例外',
820
+ 'quick.web_search': '网页搜索',
821
+ 'quick.skill': '加载技能',
822
+ 'quick.grep': '内容检索',
823
+ 'quick.glob': '按名查找文件',
824
+ 'quick.web_fetch': '抓取网页内容',
825
+ 'quick.ask_user_question': '向你提问',
826
+ 'quick.todo_write': '任务清单',
827
+ 'quick.list_agents': '列出子代理',
828
+ 'quick.job_list': '后台任务列表',
829
+ 'quick.job_output': '读取任务输出',
830
+ 'quick.job_kill': '终止后台任务',
831
+ 'quick.get_goal': '查看当前目标',
832
+ 'quick.create_goal': '创建目标',
833
+ 'quick.update_goal': '更新目标状态',
834
+ 'quick.send_message': '给子代理发消息',
835
+ 'quick.interrupt_agent': '打断子代理',
836
+ 'quick.present': '声明交付文件',
837
+ 'quick.exit_plan_mode': '退出计划模式',
838
+ 'quick.cordis_define': '定义动态插件',
839
+ 'quick.cordis_inspect_list': '检视能力清单',
840
+ 'quick.cordis_inspect_query': '执行只读检视',
841
+ 'quick.cordis_inspect_self': '检视本会话插件',
842
+ 'quick.perm_status': '查看权限状态',
843
+ 'quick.perm_set_category': '改分类默认动作',
844
+ 'quick.perm_set_fallback': '改兜底策略',
845
+ 'quick.perm_set_editor_kernel': '改编辑器内核判别',
846
+ 'quick.perm_add_exception': '添加例外',
847
+ 'quick.perm_remove_exception': '删除例外',
848
+ 'quick.perm_set_quick': '改快捷工具默认',
849
+ 'quick.perm_add_rule': '添加自定义规则',
850
+ 'quick.perm_remove_rule': '删除自定义规则',
851
+ 'quick.perm_reload': '重载配置',
852
+ 'quick.cordis_run': '运行动态插件',
853
+ 'quick.cordis_stop': '停用动态插件',
854
+ 'quick.cordis_undefine': '移除动态插件',
793
855
  'panel.excExpand': '展开',
794
856
  'panel.excCollapse': '折叠',
795
857
  'panel.excListLink': '例外列表',
@@ -825,6 +887,10 @@ window.__ModuleLoader__.load({
825
887
  'app.diffMore': '{n} more lines',
826
888
  'app.diffLoading': 'Loading…',
827
889
  'app.diffErr': 'Cannot build comparison',
890
+ 'app.imageTag': 'Image',
891
+ 'app.imageTooLarge': 'Image too large; thumbnail not generated',
892
+ 'app.imageSizeUnknown': 'Image size could not be determined; thumbnail not generated',
893
+ 'app.imageNoPreview': 'No thumbnail available',
828
894
  'app.uiErr': 'UI render error',
829
895
  'app.eolNote': 'Line endings normalized to LF for matching (CRLF/CR file with LF args): preview is indicative only; the actual edit may fail on line-ending mismatch',
830
896
  'app.diffGapShow': 'Show {n} unchanged lines',
@@ -849,13 +915,17 @@ window.__ModuleLoader__.load({
849
915
  'cat.directory': 'Directory access (outside workspace)',
850
916
  'cat.command': 'Run command',
851
917
  'cat.read': 'Read file',
918
+ 'cat.image': 'Read image',
852
919
  'cat.edit': 'Edit file',
920
+ 'cat.undo': 'Undo edit (revert last edit)',
853
921
  'cat.subagent': 'Spawn subagent',
854
922
  'cat.doomloop': 'Doom Loop',
855
923
  'catS.directory': 'Dir',
856
924
  'catS.command': 'Cmd',
857
925
  'catS.read': 'Read',
926
+ 'catS.image': 'Image',
858
927
  'catS.edit': 'Edit',
928
+ 'catS.undo': 'Undo',
859
929
  'catS.subagent': 'Sub',
860
930
  'catS.doomloop': 'Loop',
861
931
  'mode.ask': 'Ask',
@@ -889,11 +959,10 @@ window.__ModuleLoader__.load({
889
959
  'sandbox.desc.fa': 'File operations are not workspace-restricted',
890
960
  'sandbox.desc.inherit': 'Follows global (currently: {g})',
891
961
  'panel.quick': 'Quick tools (other tools)',
892
- 'panel.quickHint': 'For uncategorized tools like web search, skills, grep/glob; changes apply immediately.',
962
+ 'panel.quickHint': 'Tools without file/command semantics: set a default per tool name; anything outside this list follows the fallback policy. Changes apply immediately.',
893
963
  'panel.quickAdd': 'New tool name (e.g. todo_write, wildcards allowed)',
894
- 'panel.quickAddBtn': 'Add (allow by default)',
964
+ 'panel.quickAddBtn': 'Add',
895
965
  'panel.rules': 'Custom rules (generic matching)',
896
- 'panel.rulesHint': 'Priority: custom rules > category exceptions > category default / quick tools.',
897
966
  'panel.actionDeny': 'deny Deny',
898
967
  'panel.actionAsk': 'ask Ask',
899
968
  'panel.actionAllow': 'allow Allow',
@@ -912,12 +981,52 @@ window.__ModuleLoader__.load({
912
981
  'panel.openConfigDone': 'Opened with default editor',
913
982
  'panel.saved': 'Saved',
914
983
  'panel.sandboxSaved': 'Sandbox saved ({t}): {v}',
984
+ 'panel.fallbackSaved': 'Fallback policy saved ({t}): {v}',
985
+ 'panel.fallback': 'Fallback policy (no rule matched)',
986
+ 'panel.fallbackHint': 'How calls outside the categories above and not listed as quick tools (e.g. mcp__*, cordis_run) are handled. Default Ask: every unmatched call prompts for approval.',
915
987
  'panel.needValue': 'Enter a match value first',
988
+ 'panel.excRemaining': '{n} exception(s) with the same value remain on this path; delete them individually if not wanted',
989
+ 'panel.delFailed': 'Delete failed',
916
990
  'panel.needTool': 'Enter a tool name',
917
991
  'panel.excCount': 'Exceptions ({n})',
918
992
  'panel.excCmdHint': ': command substring/glob, overrides category default',
919
993
  'panel.excPathHint': ': path glob, overrides category default',
920
994
  'panel.excNone': 'No exceptions',
995
+ 'quick.web_search': 'web search',
996
+ 'quick.skill': 'load skill',
997
+ 'quick.grep': 'content search',
998
+ 'quick.glob': 'find files by name',
999
+ 'quick.web_fetch': 'fetch page content',
1000
+ 'quick.ask_user_question': 'ask you a question',
1001
+ 'quick.todo_write': 'todo list',
1002
+ 'quick.list_agents': 'list subagents',
1003
+ 'quick.job_list': 'list background jobs',
1004
+ 'quick.job_output': 'read job output',
1005
+ 'quick.job_kill': 'kill a background job',
1006
+ 'quick.get_goal': 'view current goal',
1007
+ 'quick.create_goal': 'create a goal',
1008
+ 'quick.update_goal': 'update goal state',
1009
+ 'quick.send_message': 'message a subagent',
1010
+ 'quick.interrupt_agent': 'interrupt a subagent',
1011
+ 'quick.present': 'present deliverable files',
1012
+ 'quick.exit_plan_mode': 'exit plan mode',
1013
+ 'quick.cordis_define': 'define a dynamic plugin',
1014
+ 'quick.cordis_inspect_list': 'list inspect capabilities',
1015
+ 'quick.cordis_inspect_query': 'run a read-only inspect query',
1016
+ 'quick.cordis_inspect_self': 'inspect session plugins',
1017
+ 'quick.perm_status': 'view permission status',
1018
+ 'quick.perm_set_category': 'set a category default',
1019
+ 'quick.perm_set_fallback': 'set the fallback policy',
1020
+ 'quick.perm_set_editor_kernel': 'set editor-kernel detection',
1021
+ 'quick.perm_add_exception': 'add an exception',
1022
+ 'quick.perm_remove_exception': 'remove an exception',
1023
+ 'quick.perm_set_quick': 'set a quick-tool default',
1024
+ 'quick.perm_add_rule': 'add a custom rule',
1025
+ 'quick.perm_remove_rule': 'remove a custom rule',
1026
+ 'quick.perm_reload': 'reload config',
1027
+ 'quick.cordis_run': 'run a dynamic plugin',
1028
+ 'quick.cordis_stop': 'stop a dynamic plugin',
1029
+ 'quick.cordis_undefine': 'remove a dynamic plugin',
921
1030
  'panel.excExpand': 'Expand',
922
1031
  'panel.excCollapse': 'Collapse',
923
1032
  'panel.excListLink': 'Exception list',
@@ -946,6 +1055,23 @@ window.__ModuleLoader__.load({
946
1055
  function catLabel(c) { return T('cat.' + c); }
947
1056
  function catShort(c) { return T('catS.' + c); }
948
1057
  function modeLabel(m) { return T('mode.' + m); }
1058
+ // 快捷工具的用途说明:仅当 i18n 有对应文案时显示(用户自加的工具名不显示说明)
1059
+ function quickDesc(t) {
1060
+ const k = 'quick.' + t;
1061
+ const d = T(k);
1062
+ return d && d !== k ? d : '';
1063
+ }
1064
+
1065
+ // 只认「配置里自己写入的键」:不能用 o[k] !== undefined 判断,那会把 Object.prototype 的成员
1066
+ // (constructor/toString 等)当成「该层已配置」,与服务端 hasOwnProperty 的口径分叉。
1067
+ function hasOwnKey(o, k) { return !!(o && Object.prototype.hasOwnProperty.call(o, k)); }
1068
+
1069
+ // 未显式配置工具的全局列显示值:与服务端 quickAction 同一条链(全局键 → 预设默认 → 会话兜底)
1070
+ function quickGlobalValue(t, gq) {
1071
+ if (hasOwnKey(gq, t)) return gq[t];
1072
+ if (hasOwnKey(QUICK_DEFAULTS, t)) return QUICK_DEFAULTS[t];
1073
+ return QUICK_FALLBACK;
1074
+ }
949
1075
  const FONT = 'system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
950
1076
  const card = { border: '1px solid rgba(128,128,128,0.35)', borderRadius: 8, padding: 12, marginBottom: 12 };
951
1077
  const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid rgba(128,128,128,0.15)', flexWrap: 'wrap' };
@@ -981,8 +1107,11 @@ window.__ModuleLoader__.load({
981
1107
  for (const key of diffFetching) if (!aliveIds.has(key)) diffFetching.delete(key);
982
1108
  for (const p of pending) {
983
1109
  if (!p.hasDiff || diffCache.has(p.id) || diffFetching.has(p.id)) continue
984
- const open = openDetail[p.id] === undefined ? true : openDetail[p.id] === true
1110
+ const userOpened = openDetail[p.id] === true
1111
+ const open = openDetail[p.id] === undefined ? true : userOpened
985
1112
  if (!open) continue
1113
+ // 图片详情是整图 data URL(体积大):不做自动预取,只有用户主动展开该条时才拉取
1114
+ if (p.imagePreview && !userOpened) continue
986
1115
  diffFetching.add(p.id)
987
1116
  schedulePrismIdle()
988
1117
  call('permgate:file-diff', { id: p.id }).then((r) => {
@@ -1007,24 +1136,36 @@ window.__ModuleLoader__.load({
1007
1136
  else next[candId] = v;
1008
1137
  setSel(next);
1009
1138
  };
1139
+ const pickedRules = (p) => (p.candidates || []).filter((c) => sel[c.id]).map((c) => ({ id: c.id, value: c.value, kind: c.kind, decision: sel[c.id] }));
1140
+ // 拒绝语义下只接受 deny 方向的规则:候选行残留的「允许此项」勾选绝不能变成持久 allow
1141
+ const pickedDenyRules = (p) => pickedRules(p).filter((r) => r.decision === 'deny');
1010
1142
  const submit = (p, action) => {
1011
- const rules = (p.candidates || []).filter((c) => sel[c.id]).map((c) => ({ value: c.value, kind: c.kind, decision: sel[c.id] }));
1012
- decide(p.id, action, rules);
1143
+ decide(p.id, action, pickedRules(p));
1013
1144
  };
1014
1145
  // 两段式拒绝:确认阶段提交(携带意见)或取消返回
1015
1146
  const confirmDeny = (p) => {
1016
1147
  const reason = (denyText[p.id] || '').trim();
1017
1148
  setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1018
- decide(p.id, 'deny', [], reason || undefined);
1149
+ decide(p.id, 'deny', pickedDenyRules(p), reason || undefined);
1019
1150
  };
1020
1151
  const cancelDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1021
- const radio = (p, c, v, label, cls) => React.createElement('button', {
1022
- className: 'pg-radio' + (sel[c.id] === v ? ' ' + cls : ''),
1023
- disabled: busyId === p.id,
1024
- onClick: () => pick(c.id, v),
1025
- }, label);
1152
+ // 进入拒绝态:只切状态,不动用户已勾选的内容。拒绝语义下「允许此项」由 radio 置灰,
1153
+ // 确认拒绝时也只提交 deny 方向(pickedDenyRules),因此点「取消」能原样回到进入前的勾选。
1154
+ const enterDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: true }));
1155
+ const radio = (p, c, v, label, cls) => {
1156
+ // 拒绝态下「允许此项」不生效(confirmDeny 只提交 deny 方向):置灰禁用而不是清除勾选,
1157
+ // 这样点「取消」保留进入前选择,点「确认拒绝」也不会把 allow 方向写进配置。
1158
+ const muted = v === 'allow' && !!denyMode[p.id];
1159
+ return React.createElement('button', {
1160
+ className: 'pg-radio' + (sel[c.id] === v ? ' ' + cls : ''),
1161
+ disabled: busyId === p.id || muted,
1162
+ style: muted ? { opacity: 0.45, cursor: 'not-allowed' } : undefined,
1163
+ onClick: () => pick(c.id, v),
1164
+ }, label);
1165
+ };
1026
1166
  // 编辑/写入审批(有 diff):详情默认展开、参数默认收起;无 diff 时参数照常显示
1027
- const detailOpen = (p) => (openDetail[p.id] === undefined ? !!p.hasDiff : openDetail[p.id]);
1167
+ // 图片详情是整图 data URL(体积大):默认收起,用户点开该条时才拉取
1168
+ const detailOpen = (p) => (openDetail[p.id] === undefined ? (!!p.hasDiff && !p.imagePreview) : openDetail[p.id]);
1028
1169
  const argsOpen = (p) => (openArgs[p.id] === undefined ? !p.hasDiff : openArgs[p.id]);
1029
1170
  const toggle = (map, setMap, p, v) => setMap(Object.assign({}, map, { [p.id]: v }));
1030
1171
  // 点击文件名 → 打开右侧对比抽屉(父组件 OverlayRoot 持有 pin 状态)
@@ -1066,9 +1207,7 @@ window.__ModuleLoader__.load({
1066
1207
  const c = diffCache.get(p.id)
1067
1208
  if (!c) return React.createElement('div', { className: 'pg2-load' }, T('app.diffLoading'))
1068
1209
  if (!c.ok) return React.createElement('div', { className: 'pg2-err' }, (c.error || T('app.diffErr')))
1069
- return c.kind === 'read'
1070
- ? React.createElement(PGErrorBoundary, { resetKey: payloadKey(c), fallback: T('app.diffErr') }, React.createElement(ReadBlockMemo, { data: c, onOpenFile: (f) => openFile(p, f) }))
1071
- : React.createElement(PGErrorBoundary, { resetKey: payloadKey(c), fallback: T('app.diffErr') }, React.createElement(DiffBlockMemo, { data: c, onOpenFile: (f) => openFile(p, f), changesOnly: true }))
1210
+ return detailBody(c, { onOpenFile: (f) => openFile(p, f), changesOnly: true, resetKey: payloadKey(c) })
1072
1211
  })() : null,
1073
1212
  ) : null,
1074
1213
  (p.candidates || []).length ? React.createElement('div', null,
@@ -1093,7 +1232,7 @@ window.__ModuleLoader__.load({
1093
1232
  React.createElement('button', { className: 'pg-action', disabled: busyId === p.id, onClick: () => cancelDeny(p) }, T('panel.cancel')),
1094
1233
  ),
1095
1234
  ) : React.createElement('div', { className: 'pg-footer' },
1096
- React.createElement('button', { className: 'pg-action pg-action-deny', disabled: busyId === p.id, onClick: () => setDenyMode(Object.assign({}, denyMode, { [p.id]: true })) }, T('app.deny')),
1235
+ React.createElement('button', { className: 'pg-action pg-action-deny', disabled: busyId === p.id, onClick: () => enterDeny(p) }, T('app.deny')),
1097
1236
  React.createElement('button', { className: 'pg-action pg-action-allow', disabled: busyId === p.id, onClick: () => submit(p, 'allow') }, T('app.allow')),
1098
1237
  ),
1099
1238
  )),
@@ -1323,6 +1462,49 @@ window.__ModuleLoader__.load({
1323
1462
  const diffPropsEqual = (a, b) => a.data === b.data && a.changesOnly === b.changesOnly;
1324
1463
  const DiffBlockMemo = React.memo(DiffBlock, diffPropsEqual);
1325
1464
  const ReadBlockMemo = React.memo(ReadBlock, diffPropsEqual);
1465
+
1466
+ // kind → 详情组件 分派单点:待审批内联详情与右侧对比抽屉共用,新增 kind(如 image)只改这一处,
1467
+ // 避免两处漏改导致某处把数据落到 DiffBlock,被错误边界兜成「无法生成对比」。
1468
+ function detailBody(data, opts) {
1469
+ const o = opts || {};
1470
+ const fb = T('app.diffErr');
1471
+ const boundaryProps = o.resetKey !== undefined ? { resetKey: o.resetKey, fallback: fb } : { fallback: fb };
1472
+ if (data.kind === 'image') return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(ImageBlock, { data }));
1473
+ if (data.kind === 'read') return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(ReadBlockMemo, { data, onOpenFile: o.onOpenFile || null, onCollapse: o.onCollapse }));
1474
+ return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(DiffBlockMemo, { data, onOpenFile: o.onOpenFile || null, onCollapse: o.onCollapse, changesOnly: o.changesOnly }));
1475
+ }
1476
+
1477
+ // 图片详情块:头部显示 格式 · 像素尺寸 · 体积,下面给缩略图;
1478
+ // 超过上限或读取失败时不给图片本体,改显示说明文本(宿主下发的 error 由外层按 .pg2-err 渲染)。
1479
+ function ImageBlock({ data }) {
1480
+ const fmtBytes = (n) => {
1481
+ const v = Number(n) || 0;
1482
+ if (v < 1024) return v + ' B';
1483
+ if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
1484
+ return (v / (1024 * 1024)).toFixed(2) + ' MB';
1485
+ };
1486
+ const meta = [
1487
+ String(data.format || '').toUpperCase(),
1488
+ data.width && data.height ? data.width + '×' + data.height : '',
1489
+ data.size ? fmtBytes(data.size) : '',
1490
+ ].filter(Boolean).join(' · ');
1491
+ const note = data.sizeUnknown
1492
+ ? T('app.imageSizeUnknown')
1493
+ : data.tooLarge
1494
+ ? T('app.imageTooLarge') + (data.limit ? '(' + fmtBytes(data.limit) + ')' : '')
1495
+ : T('app.imageNoPreview');
1496
+ return React.createElement('div', { className: 'pg2-block' },
1497
+ React.createElement('div', { className: 'pg2-header' },
1498
+ React.createElement('span', { className: 'pg2-status pg2-status-read' }, T('app.imageTag')),
1499
+ React.createElement('span', { className: 'pg2-path', title: data.file }, data.file),
1500
+ meta ? React.createElement('span', { style: { marginLeft: 'auto', color: 'rgba(128,128,128,0.9)' } }, meta) : null,
1501
+ ),
1502
+ data.dataUrl
1503
+ ? React.createElement('div', { style: { padding: 10, display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'auto', background: 'rgba(128,128,128,0.06)' } },
1504
+ React.createElement('img', { src: data.dataUrl, alt: data.file, style: { maxWidth: '100%', maxHeight: 320, objectFit: 'contain', borderRadius: 4, display: 'block' } }))
1505
+ : React.createElement('div', { className: 'pg2-foot' }, note),
1506
+ );
1507
+ }
1326
1508
  function CompareDrawer({ pin, onClose }) {
1327
1509
  const [data, setData] = React.useState(null);
1328
1510
  const [err, setErr] = React.useState('');
@@ -1388,9 +1570,7 @@ window.__ModuleLoader__.load({
1388
1570
  };
1389
1571
  let body;
1390
1572
  if (data) {
1391
- body = data.kind === 'read'
1392
- ? React.createElement(PGErrorBoundary, { fallback: T('app.diffErr') }, React.createElement(ReadBlockMemo, { data, onOpenFile: null, onCollapse: onClose }))
1393
- : React.createElement(PGErrorBoundary, { fallback: T('app.diffErr') }, React.createElement(DiffBlockMemo, { data, onOpenFile: null, onCollapse: onClose }));
1573
+ body = detailBody(data, { onCollapse: onClose })
1394
1574
  } else if (err) {
1395
1575
  body = React.createElement('div', { className: 'pg2-err' }, err);
1396
1576
  } else {
@@ -1477,7 +1657,7 @@ window.__ModuleLoader__.load({
1477
1657
  return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'rgba(128,128,128,0.95)', padding: '2px 0' } },
1478
1658
  React.createElement('span', { style: { fontWeight: 600 } }, T('dock.title')),
1479
1659
  React.createElement('span', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1480
- chip(catShort('directory'), eff.directory), chip(catShort('command'), eff.command), chip(catShort('read'), eff.read), chip(catShort('edit'), eff.edit), chip(catShort('subagent'), eff.subagent), chip(catShort('doomloop'), eff.doomloop),
1660
+ chip(catShort('directory'), eff.directory), chip(catShort('command'), eff.command), chip(catShort('read'), eff.read), chip(catShort('image'), eff.image), chip(catShort('edit'), eff.edit), chip(catShort('undo'), eff.undo), chip(catShort('subagent'), eff.subagent), chip(catShort('doomloop'), eff.doomloop),
1481
1661
  ),
1482
1662
  React.createElement('span', { style: { cursor: 'pointer', padding: '0 4px' }, onClick: refresh, title: T('dock.refresh') }, '↻'),
1483
1663
  );
@@ -1498,6 +1678,7 @@ window.__ModuleLoader__.load({
1498
1678
  const [exAction, setExAction] = React.useState('allow');
1499
1679
  const [exReasonVal, setExReasonVal] = React.useState('');
1500
1680
  const [newTool, setNewTool] = React.useState('');
1681
+ const [newToolAction, setNewToolAction] = React.useState('allow');
1501
1682
  const [form, setForm] = React.useState({ action: 'deny', tool: '', path: '', args: '', reason: '' });
1502
1683
  const [confirm, setConfirm] = React.useState(null);
1503
1684
  const [excCollapsed, setExcCollapsed] = React.useState({});
@@ -1508,8 +1689,13 @@ window.__ModuleLoader__.load({
1508
1689
  return () => clearTimeout(id);
1509
1690
  }, [confirm]);
1510
1691
 
1692
+ // 切换全局/项目 tab 时清掉未完成的二次确认:确认的对象是「某一层的某个键」,换层后不能沿用
1693
+ // 同一确认态,否则再点会删掉另一层。
1694
+ React.useEffect(() => { setConfirm(null); }, [tab]);
1695
+
1511
1696
  const applyStatus = (s) => {
1512
1697
  if (!s) return;
1698
+ applyStatusLists(s);
1513
1699
  setStatus(s);
1514
1700
  const cs = { global: {}, project: {} };
1515
1701
  for (const t of ['global', 'project']) {
@@ -1522,12 +1708,15 @@ window.__ModuleLoader__.load({
1522
1708
  const qs = {};
1523
1709
  const gq = s.quickTools ? s.quickTools.global : {};
1524
1710
  const pq = s.quickTools ? s.quickTools.project : {};
1711
+ // 会话兜底只作最后一级(预设之外、且未配置的工具);宿主 statusView 恒下发合法的
1712
+ // fallback.effective,下面的 `|| 'ask'` 只是协议异常兜底,不是决策链的一环。
1713
+ QUICK_FALLBACK = (s.fallback && s.fallback.effective) || 'ask';
1525
1714
  const names = {};
1526
1715
  for (const t of QUICK_PRESET) names[t] = 1;
1527
1716
  for (const k of Object.keys(gq)) names[k] = 1;
1528
1717
  for (const k of Object.keys(pq)) names[k] = 1;
1529
1718
  for (const t of Object.keys(names)) {
1530
- qs[t] = { g: gq[t] || 'allow', p: pq[t] && pq[t] !== 'inherit' ? pq[t] : 'inherit' };
1719
+ qs[t] = { g: quickGlobalValue(t, gq), p: hasOwnKey(pq, t) && pq[t] !== 'inherit' ? pq[t] : 'inherit' };
1531
1720
  }
1532
1721
  setQuickSel(qs);
1533
1722
  };
@@ -1577,7 +1766,14 @@ window.__ModuleLoader__.load({
1577
1766
 
1578
1767
  const addQuick = () => {
1579
1768
  if (!newTool || !String(newTool).trim()) { setMsg(T('panel.needTool')); return; }
1580
- invoke('permgate:set-quick', { target: tab, tool: String(newTool).trim(), action: 'allow' }, () => setNewTool(''));
1769
+ invoke('permgate:set-quick', { target: tab, tool: String(newTool).trim(), action: newToolAction }, () => setNewTool(''));
1770
+ };
1771
+
1772
+ // 删除该行在当前 tab 的快捷工具设置(服务端收到 action=inherit 即删除该键);
1773
+ // 只发请求、不做本地乐观删除:quickSel 完全由服务端返回的 status 收敛,请求失败时
1774
+ // 不会留下「面板已删、服务端仍生效」的漂移(与例外/规则的删除入口一致)。
1775
+ const removeQuick = (tool) => {
1776
+ invoke('permgate:set-quick', { target: tab, tool, action: 'inherit' });
1581
1777
  };
1582
1778
 
1583
1779
  const addException = (c) => {
@@ -1588,7 +1784,18 @@ window.__ModuleLoader__.load({
1588
1784
  invoke('permgate:add-exception', { target: tab, category: c, match: v, action: exAction, reason: exAction === 'deny' ? (String(exReasonVal || '').trim() || undefined) : undefined });
1589
1785
  };
1590
1786
 
1591
- const removeException = (c, id) => invoke('permgate:remove-exception', { target: tab, category: c, id });
1787
+ const removeException = (c, id) => {
1788
+ setBusy(true);
1789
+ setMsg('');
1790
+ // 注意:invoke 是无返回值的回调式封装,这里必须用返回 promise 的 call
1791
+ call('permgate:remove-exception', { target: tab, category: c, id }).then((r) => {
1792
+ if (r && r.error) { setMsg(String(r.error)); return; }
1793
+ applyStatus(r && r.status ? r.status : r);
1794
+ // 删除只作用于选中行:明确反馈结果;同路径若仍有例外则提示剩余条数,否则清掉旧提示
1795
+ if (r && r.removed === false) setMsg(r.reason ? String(r.reason) : T('panel.delFailed'));
1796
+ else setMsg(r && r.remaining > 0 ? T('panel.excRemaining').replace('{n}', String(r.remaining)) : '');
1797
+ }).catch((e) => setMsg(String((e && e.message) || e))).then(() => setBusy(false));
1798
+ };
1592
1799
 
1593
1800
  const setFormKey = (key) => (e) => setForm(Object.assign({}, form, { [key]: e.target.value }));
1594
1801
  const addRule = () => invoke('permgate:add-rule', { target: tab, action: form.action, tool: form.tool || undefined, path: form.path || undefined, args: form.args || undefined, reason: form.reason || undefined });
@@ -1651,13 +1858,31 @@ window.__ModuleLoader__.load({
1651
1858
  );
1652
1859
  };
1653
1860
 
1654
- const quickTools = Object.keys(quickSel);
1861
+ // 行集合按 tab 区分:项目 tab 显示全集(预设 + 全局键 + 项目键),便于在项目里覆盖全局;
1862
+ // 全局 tab 只显示「预设 + 全局配置里确实存在的键」,避免项目层专有的键在这里冒充全局设置。
1863
+ const quickGq = status && status.quickTools ? status.quickTools.global : null;
1864
+ const quickTools = Object.keys(quickSel).filter((t) => {
1865
+ if (tab === 'project') return true;
1866
+ if (QUICK_PRESET.indexOf(t) !== -1) return true;
1867
+ return hasOwnKey(quickGq, t);
1868
+ });
1655
1869
  const quickRow = (t) => {
1656
1870
  const key = tab === 'global' ? 'g' : 'p';
1657
- const mode = quickSel[t] ? (quickSel[t][key] || (tab === 'project' ? 'inherit' : 'allow')) : (tab === 'project' ? 'inherit' : 'allow');
1871
+ const gqMap = status && status.quickTools ? status.quickTools.global : null;
1872
+ const pqMap = status && status.quickTools ? status.quickTools.project : null;
1873
+ const mode = quickSel[t] && quickSel[t][key] ? quickSel[t][key] : (tab === 'project' ? 'inherit' : quickGlobalValue(t, gqMap));
1874
+ // 删除按钮只给「用户自己添加的名字」(不在 22 个预设清单里的行),且本行在当前 tab 的配置里
1875
+ // 确实存在该键时才显示:预设行始终会列出,删掉配置键也只是回退默认值,容易被误读成「收紧」。
1876
+ const isPreset = QUICK_PRESET.indexOf(t) !== -1;
1877
+ const hasKey = key === 'g' ? hasOwnKey(gqMap, t) : hasOwnKey(pqMap, t);
1878
+ const desc = quickDesc(t);
1658
1879
  return React.createElement('div', { key: t, style: rowStyle },
1659
- React.createElement('span', { style: { fontFamily: 'monospace', fontSize: 13, width: 150 } }, t),
1880
+ React.createElement('span', { style: { display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 260 } },
1881
+ React.createElement('span', { style: { fontFamily: 'monospace', fontSize: 13 } }, t),
1882
+ desc ? React.createElement('span', { style: { fontSize: 12, color: 'rgba(128,128,128,0.9)' } }, desc) : null,
1883
+ ),
1660
1884
  sel(mode, changeQuick(t), tab === 'project' ? ALL_MODES : MODES, busy),
1885
+ (!isPreset && hasKey) ? React.createElement('button', { className: 'pg-btn pg-btn-danger' + (confirm === 'quick:' + tab + ':' + t ? ' pg-btn-confirm' : ''), disabled: busy, onClick: () => confirmDelete('quick:' + tab + ':' + t, () => removeQuick(t)) }, confirm === 'quick:' + tab + ':' + t ? T('panel.confirmDel') : T('panel.del')) : null,
1661
1886
  );
1662
1887
  };
1663
1888
 
@@ -1687,6 +1912,11 @@ window.__ModuleLoader__.load({
1687
1912
 
1688
1913
  const sandboxLabel = (m) => (m === 'workspace-write' ? T('sandbox.ww') : m === 'danger-full-access' ? T('sandbox.fa') : T('sandbox.inherit'));
1689
1914
  const sandboxVal = tab === 'global' ? sandbox.global : sandbox.project;
1915
+ const fbVal = status && status.fallback ? (tab === 'global' ? status.fallback.global : status.fallback.project) : (tab === 'global' ? 'ask' : 'inherit');
1916
+ const changeFallback = (e) => {
1917
+ const mode = e.target.value;
1918
+ invoke('permgate:set-fallback', { target: tab, mode }, () => setMsg(T('panel.fallbackSaved').replace('{t}', T(tab === 'global' ? 'panel.tabGlobal' : 'panel.tabProject')).replace('{v}', modeLabel(mode))));
1919
+ };
1690
1920
  const sandboxOptions = tab === 'global' ? ['workspace-write', 'danger-full-access'] : ['workspace-write', 'danger-full-access', 'inherit'];
1691
1921
  const changeSandbox = (e) => {
1692
1922
  const mode = e.target.value;
@@ -1745,12 +1975,12 @@ window.__ModuleLoader__.load({
1745
1975
  quickTools.map(quickRow),
1746
1976
  React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
1747
1977
  React.createElement('input', { className: 'pg-field', placeholder: T('panel.quickAdd'), value: newTool, onChange: (e) => setNewTool(e.target.value), disabled: busy }),
1978
+ sel(newToolAction, (e) => setNewToolAction(e.target.value), MODES, busy),
1748
1979
  React.createElement('button', { className: 'pg-btn', disabled: busy, onClick: addQuick }, T('panel.quickAddBtn')),
1749
1980
  ),
1750
1981
  ),
1751
1982
  React.createElement('div', { style: card },
1752
1983
  React.createElement('div', { style: h() }, T('panel.rules')),
1753
- React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.8)', marginBottom: 8 } }, T('panel.rulesHint')),
1754
1984
  React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginBottom: 8 } },
1755
1985
  React.createElement('select', { value: form.action, onChange: setFormKey('action'), className: 'pg-field', disabled: busy },
1756
1986
  React.createElement('option', { value: 'deny' }, T('panel.actionDeny')), React.createElement('option', { value: 'ask' }, T('panel.actionAsk')), React.createElement('option', { value: 'allow' }, T('panel.actionAllow')),
@@ -1766,6 +1996,13 @@ window.__ModuleLoader__.load({
1766
1996
  customList && customList.length ? customList.map(ruleRow) : React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.8)' } }, T('panel.noRules')),
1767
1997
  ),
1768
1998
  ),
1999
+ React.createElement('div', { style: card },
2000
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 } },
2001
+ React.createElement('span', { style: { fontSize: 13, fontWeight: 600 } }, T('panel.fallback')),
2002
+ sel(fbVal, changeFallback, tab === 'global' ? MODES : ALL_MODES, busy),
2003
+ ),
2004
+ React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.85)' } }, T('panel.fallbackHint')),
2005
+ ),
1769
2006
  React.createElement('div', { style: card },
1770
2007
  React.createElement('div', { style: h() }, T('panel.decisions')),
1771
2008
  decisions.length ? decisions.map((d, i) => React.createElement('div', { key: i, style: rowStyle },