tavern-harness 0.1.15 → 0.1.17

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/CHANGELOG.md CHANGED
@@ -3,6 +3,52 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.17] - 2026-10-25
7
+
8
+ ### 中文
9
+
10
+ #### 改进
11
+
12
+ - 在工作区文件管理器中预览文件时不再关闭管理器:预览复用管理器的遮罩层,关闭预览即可回到原来的浏览位置
13
+ - 文件删除确认改为管理器内的弹出对话框,删除过程中按钮显示加载状态,失败时会提示原因,不再静默关闭
14
+
15
+ #### 修复
16
+
17
+ - 修复在 Termux / Android 环境下 shell 命令无法解析的问题:现在会从 Termux 的 `$PREFIX/bin` 中查找受信任的命令,并保留 `head` 等 applet 的原始名称
18
+
19
+ ### English
20
+
21
+ #### Changed
22
+
23
+ - Previewing a file from the workspace file manager no longer closes the manager: the preview reuses the manager's overlay, and closing it returns you to the same browsing position
24
+ - Delete confirmation is now a dialog inside the manager; the delete button shows a loading state while the request runs, and failures surface the reason instead of closing silently
25
+
26
+ #### Fixed
27
+
28
+ - Fixed shell commands failing to resolve on Termux / Android: trusted commands are now looked up in Termux's `$PREFIX/bin`, and applets such as `head` keep their original name
29
+
30
+ ## [0.1.16] - 2026-10-25
31
+
32
+ ### 中文
33
+
34
+ #### 修复
35
+
36
+ - 修复工作区文件管理器在触屏设备上删除按钮不可见、文件夹无法删除的问题:删除按钮现在始终显示,文件夹及其内容可以一并删除,`public` 共享链接仍不可删除
37
+
38
+ #### 内部
39
+
40
+ - 工作区删除接口支持递归删除目录并拒绝删除 `public` 软链接,补充对应测试
41
+
42
+ ### English
43
+
44
+ #### Fixed
45
+
46
+ - Fixed the delete button staying hidden on touch devices and folders not being deletable in the workspace file manager: the button is now always visible, folders and their contents can be removed together, and the `public` shared link remains protected
47
+
48
+ #### Internal
49
+
50
+ - The workspace delete endpoint now removes directories recursively and refuses to delete the `public` symlink, with tests
51
+
6
52
  ## [0.1.15] - 2026-10-25
7
53
 
8
54
  ### 中文
@@ -444,6 +490,8 @@
444
490
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
445
491
 
446
492
  <!-- 链接定义 -->
493
+ [0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
494
+ [0.1.16]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.15...v0.1.16
447
495
  [0.1.15]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.14...v0.1.15
448
496
  [0.1.14]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.13...v0.1.14
449
497
  [0.1.13]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.12...v0.1.13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -336,9 +336,12 @@ const SHELL_ALLOWLIST_GROUPS = {
336
336
  };
337
337
  const SHELL_ALLOWED_REAL = new Set(Object.values(SHELL_ALLOWLIST_GROUPS).flat());
338
338
 
339
+ const TERMUX_BIN = process.platform === 'android' && process.env.PREFIX
340
+ ? join(process.env.PREFIX, 'bin')
341
+ : null;
339
342
  const TRUSTED_COMMAND_DIRS = (IS_WIN
340
343
  ? [GIT_USR_BIN]
341
- : ['/bin', '/usr/bin', '/usr/sbin', '/sbin'])
344
+ : ['/bin', '/usr/bin', '/usr/sbin', '/sbin', TERMUX_BIN])
342
345
  .filter(Boolean)
343
346
  .map((dir) => {
344
347
  try {
@@ -358,10 +361,15 @@ function resolveTrustedCommand(command) {
358
361
  try {
359
362
  const resolved = realpathSync(candidate);
360
363
  if (!resolved.startsWith(trustedDir + sep)) return null;
361
- if (IS_WIN) return resolved; // Windows 无 POSIX 权限位(Git usr/bin 的 exe 显示 666),跳过 uid/writable 校验
364
+ if (IS_WIN) return candidate; // Windows 无 POSIX 权限位(Git usr/bin 的 exe 显示 666),跳过 uid/writable 校验
362
365
  const executable = statSync(resolved);
363
366
  const writableByNonOwner = (executable.mode & 0o022) !== 0;
364
- if (executable.uid === 0 && !writableByNonOwner) return resolved;
367
+ const trustedSystemExecutable = executable.uid === 0 && !writableByNonOwner;
368
+ const trustedTermuxExecutable = trustedDir === TERMUX_BIN
369
+ && executable.uid === process.getuid()
370
+ && !writableByNonOwner;
371
+ // Preserve the applet path: resolving /system/bin/head to toybox loses the applet name.
372
+ if (trustedSystemExecutable || trustedTermuxExecutable) return candidate;
365
373
  } catch { /* try next candidate */ }
366
374
  }
367
375
  }
@@ -670,11 +678,20 @@ const server = createServer(async (req, res) => {
670
678
  throw new Error('Path is outside the workspace');
671
679
  }
672
680
  const fp = resolve(base, rel);
673
- if (!existsSync(fp) || !lstatSync(fp).isFile()) throw new Error('File not found');
681
+ if (!existsSync(fp)) throw new Error('File or directory not found');
682
+ const entry = lstatSync(fp);
683
+ if (entry.isSymbolicLink() && rel === 'public') {
684
+ throw new Error('The public workspace link cannot be deleted');
685
+ }
686
+ if (!entry.isFile() && !entry.isDirectory()) throw new Error('Path is not a file or directory');
674
687
  if (!deletingPublicLink) assertInside(base, realpathSync(fp));
675
- await new Promise((resolveDelete, rejectDelete) => {
676
- unlink(fp, (err) => (err ? rejectDelete(err) : resolveDelete()));
677
- });
688
+ if (entry.isDirectory()) {
689
+ rmSync(fp, { recursive: true, force: false });
690
+ } else {
691
+ await new Promise((resolveDelete, rejectDelete) => {
692
+ unlink(fp, (err) => (err ? rejectDelete(err) : resolveDelete()));
693
+ });
694
+ }
678
695
  res.end(JSON.stringify({ ok: true, path: rel }));
679
696
  } else {
680
697
  // file_list:返回会话工作目录内的相对路径(分组目录)
@@ -128,7 +128,7 @@ export function FileDisplayModal() {
128
128
  setTextWrap(true);
129
129
  if (isNewDisplay) {
130
130
  setPipRect(createPipRect());
131
- setIsPictureInPicture(active.presentation !== 'modal');
131
+ setIsPictureInPicture(active.presentation === 'pip' || active.presentation == null);
132
132
  }
133
133
  }, [active]);
134
134
 
@@ -263,6 +263,7 @@ export function FileDisplayModal() {
263
263
 
264
264
  const title = active?.title?.trim() || (active ? basenameOf(active.path) : '');
265
265
  const ext = active ? extensionOf(active.path) : '';
266
+ const isWorkspacePreview = active?.presentation === 'workspace';
266
267
 
267
268
  const close = () => setActiveDisplay(null);
268
269
 
@@ -485,10 +486,10 @@ export function FileDisplayModal() {
485
486
 
486
487
  return createPortal(
487
488
  <>
488
- {!isPictureInPicture && <div key="overlay" className="overlay" />}
489
+ {!isPictureInPicture && !isWorkspacePreview && <div key="overlay" className="overlay" />}
489
490
  <div
490
491
  key="display-container"
491
- className={isPictureInPicture ? 'display-pip card' : 'modal-root'}
492
+ className={isPictureInPicture ? 'display-pip card' : 'modal-root display-modal-root'}
492
493
  style={isPictureInPicture
493
494
  ? { left: pipRect.x, top: pipRect.y, width: pipRect.width, height: pipRect.height }
494
495
  : undefined}
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { Modal, Icon } from './shared';
3
- import { DeleteConfirmDialog } from './DeleteConfirmDialog';
4
3
  import { useT } from '../core/i18n';
5
4
  import { applySessionWorkspace } from '../core/tools/toolExecutor';
6
5
  import { deleteWorkspaceFile, listSessionWorkspaceFiles, openSessionWorkspace } from '../core/tools/generatedSkillExecutor';
@@ -11,7 +10,7 @@ import { useStore } from '../store/store';
11
10
  // - 会话头部的工作目录标签点击打开
12
11
  // - 只展示当前会话专属磁盘工作区(sandbox_workspace/)
13
12
  // - 目录点击进入、支持返回上级与面包屑;文件点击复用 file_display 弹窗预览
14
- // - 可在确认后删除文件(不删除目录)
13
+ // - 可在确认后删除文件或目录(public 软链接除外)
15
14
  // ============================================================
16
15
 
17
16
  function extOf(path: string): string {
@@ -59,6 +58,7 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
59
58
  const [files, setFiles] = useState<string[] | null>(null);
60
59
  const [loadState, setLoadState] = useState<'loading' | 'ok' | 'empty' | 'error'>('loading');
61
60
  const [pendingDelete, setPendingDelete] = useState<Node | null>(null);
61
+ const [deleting, setDeleting] = useState(false);
62
62
  const [openingLocation, setOpeningLocation] = useState(false);
63
63
  // 当前浏览目录('' = 根;否则为以 / 结尾的会话内相对路径)
64
64
  const [dir, setDir] = useState('');
@@ -111,20 +111,29 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
111
111
  kind: kindFromPath(node.path),
112
112
  title: node.name,
113
113
  sessionId,
114
- presentation: 'modal',
114
+ presentation: 'workspace',
115
115
  });
116
- onClose();
117
116
  };
118
117
 
119
118
  const confirmDelete = async () => {
120
- if (!pendingDelete) return;
121
- const workspaceDir = await applySessionWorkspace(sessionId);
122
- await deleteWorkspaceFile(pendingDelete.path, workspaceDir);
123
- setFiles((current) => {
124
- const next = (current ?? []).filter((path) => path !== pendingDelete.path);
125
- setLoadState(next.length === 0 ? 'empty' : 'ok');
126
- return next;
127
- });
119
+ if (!pendingDelete || deleting) return;
120
+ setDeleting(true);
121
+ try {
122
+ const workspaceDir = await applySessionWorkspace(sessionId);
123
+ await deleteWorkspaceFile(pendingDelete.path, workspaceDir);
124
+ setFiles((current) => {
125
+ const next = (current ?? []).filter((path) => (
126
+ pendingDelete.isDir ? !path.startsWith(pendingDelete.path) : path !== pendingDelete.path
127
+ ));
128
+ setLoadState(next.length === 0 ? 'empty' : 'ok');
129
+ return next;
130
+ });
131
+ setPendingDelete(null);
132
+ } catch (error) {
133
+ addToast((error as Error).message, 'error');
134
+ } finally {
135
+ setDeleting(false);
136
+ }
128
137
  };
129
138
 
130
139
  const openWorkspaceLocation = async () => {
@@ -197,44 +206,58 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
197
206
  <span className="ws-fm-name">..</span>
198
207
  </div>
199
208
  )}
200
- {visible.map((n) =>
201
- n.isDir ? (
202
- <div key={n.path} className="ws-fm-row" role="button" onClick={() => setDir(n.path)} title={n.path}>
209
+ {visible.map((n) => {
210
+ const isPublicLink = n.isDir && n.path === 'public/';
211
+ return (
212
+ <div
213
+ key={n.path}
214
+ className="ws-fm-row"
215
+ role="button"
216
+ onClick={() => n.isDir ? setDir(n.path) : openFile(n)}
217
+ title={n.path}
218
+ >
203
219
  <span className="ws-fm-arrow" />
204
- <Icon name="folder" size={14} />
205
- <span className="ws-fm-name">{n.name}/</span>
220
+ <Icon name={n.isDir ? 'folder' : 'file'} size={14} />
221
+ <span className={`ws-fm-name${n.isDir ? '' : ' mono'}`}>{n.name}{n.isDir ? '/' : ''}</span>
222
+ {!isPublicLink && (
223
+ <span className="ws-fm-actions">
224
+ <button
225
+ className="icon-btn ws-fm-delete"
226
+ title={t('common.delete')}
227
+ aria-label={t('common.delete')}
228
+ onClick={(event) => {
229
+ event.stopPropagation();
230
+ setPendingDelete(n);
231
+ }}
232
+ ><Icon name="trash" size={13} /></button>
233
+ </span>
234
+ )}
206
235
  </div>
207
- ) : (
208
- <div key={n.path} className="ws-fm-row" role="button" onClick={() => openFile(n)} title={n.path}>
209
- <span className="ws-fm-arrow" />
210
- <Icon name="file" size={14} />
211
- <span className="ws-fm-name mono">{n.name}</span>
212
- <span className="ws-fm-actions">
213
- <span className="ws-fm-open"><Icon name="eye" size={12} /></span>
214
- <button
215
- className="icon-btn ws-fm-delete"
216
- title={t('common.delete')}
217
- aria-label={t('common.delete')}
218
- onClick={(event) => {
219
- event.stopPropagation();
220
- setPendingDelete(n);
221
- }}
222
- ><Icon name="trash" size={13} /></button>
223
- </span>
224
- </div>
225
- )
226
- )}
236
+ );
237
+ })}
227
238
  {visible.length === 0 && <div className="ws-fm-empty">{t('header.fileManagerEmpty')}</div>}
228
239
  </div>
229
240
  )}
230
241
  </div>
231
242
  {pendingDelete && (
232
- <DeleteConfirmDialog
233
- title={t('header.fileManagerDeleteTitle')}
234
- itemName={pendingDelete.path}
235
- onCancel={() => setPendingDelete(null)}
236
- onConfirm={confirmDelete}
237
- />
243
+ <div className="ws-fm-delete-layer" role="dialog" aria-modal="true" aria-labelledby="ws-fm-delete-title">
244
+ <div className="ws-fm-delete-dialog card fade-up">
245
+ <div className="modal-head">
246
+ <span id="ws-fm-delete-title" style={{ fontWeight: 800, fontSize: 15 }}>{t('header.fileManagerDeleteTitle')}</span>
247
+ <button className="icon-btn" onClick={() => setPendingDelete(null)} disabled={deleting} title={t('common.close')}>
248
+ <Icon name="x" />
249
+ </button>
250
+ </div>
251
+ <div className="modal-body">{t('common.confirmDelete', { name: pendingDelete.path })}</div>
252
+ <div className="modal-foot">
253
+ <button className="btn" onClick={() => setPendingDelete(null)} disabled={deleting}>{t('common.cancel')}</button>
254
+ <button className="btn btn-danger" onClick={() => void confirmDelete()} disabled={deleting}>
255
+ {deleting ? <span className="spinner" style={{ width: 13, height: 13 }} /> : <Icon name="trash" size={13} />}
256
+ {t('common.delete')}
257
+ </button>
258
+ </div>
259
+ </div>
260
+ </div>
238
261
  )}
239
262
  </Modal>
240
263
  );
@@ -462,7 +462,7 @@ export async function writeWorkspaceFileTextFor(
462
462
  return diskFileWrite(normalizedPath, content, false, workspaceDir);
463
463
  }
464
464
 
465
- /** 删除工作区中的单个文件。 */
465
+ /** 删除工作区中的文件或目录。 */
466
466
  export async function deleteWorkspaceFile(path: string, workspaceDir: string): Promise<void> {
467
467
  const normalizedPath = normalizePath(path);
468
468
  if (!normalizedPath) throw new Error('Invalid path');
@@ -1,7 +1,81 @@
1
1
  {
2
- "latest": "0.1.15",
2
+ "latest": "0.1.17",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.17",
7
+ "date": "2026-10-25",
8
+ "breaking": false,
9
+ "compare": "v0.1.16...v0.1.17",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "changed",
14
+ "items": [
15
+ "在工作区文件管理器中预览文件时不再关闭管理器:预览复用管理器的遮罩层,关闭预览即可回到原来的浏览位置",
16
+ "文件删除确认改为管理器内的弹出对话框,删除过程中按钮显示加载状态,失败时会提示原因,不再静默关闭"
17
+ ]
18
+ },
19
+ {
20
+ "kind": "fixed",
21
+ "items": [
22
+ "修复在 Termux / Android 环境下 shell 命令无法解析的问题:现在会从 Termux 的 `$PREFIX/bin` 中查找受信任的命令,并保留 `head` 等 applet 的原始名称"
23
+ ]
24
+ }
25
+ ],
26
+ "en": [
27
+ {
28
+ "kind": "changed",
29
+ "items": [
30
+ "Previewing a file from the workspace file manager no longer closes the manager: the preview reuses the manager's overlay, and closing it returns you to the same browsing position",
31
+ "Delete confirmation is now a dialog inside the manager; the delete button shows a loading state while the request runs, and failures surface the reason instead of closing silently"
32
+ ]
33
+ },
34
+ {
35
+ "kind": "fixed",
36
+ "items": [
37
+ "Fixed shell commands failing to resolve on Termux / Android: trusted commands are now looked up in Termux's `$PREFIX/bin`, and applets such as `head` keep their original name"
38
+ ]
39
+ }
40
+ ]
41
+ }
42
+ },
43
+ {
44
+ "version": "0.1.16",
45
+ "date": "2026-10-25",
46
+ "breaking": false,
47
+ "compare": "v0.1.15...v0.1.16",
48
+ "groups": {
49
+ "zh-CN": [
50
+ {
51
+ "kind": "fixed",
52
+ "items": [
53
+ "修复工作区文件管理器在触屏设备上删除按钮不可见、文件夹无法删除的问题:删除按钮现在始终显示,文件夹及其内容可以一并删除,`public` 共享链接仍不可删除"
54
+ ]
55
+ },
56
+ {
57
+ "kind": "internal",
58
+ "items": [
59
+ "工作区删除接口支持递归删除目录并拒绝删除 `public` 软链接,补充对应测试"
60
+ ]
61
+ }
62
+ ],
63
+ "en": [
64
+ {
65
+ "kind": "fixed",
66
+ "items": [
67
+ "Fixed the delete button staying hidden on touch devices and folders not being deletable in the workspace file manager: the button is now always visible, folders and their contents can be removed together, and the `public` shared link remains protected"
68
+ ]
69
+ },
70
+ {
71
+ "kind": "internal",
72
+ "items": [
73
+ "The workspace delete endpoint now removes directories recursively and refuses to delete the `public` symlink, with tests"
74
+ ]
75
+ }
76
+ ]
77
+ }
78
+ },
5
79
  {
6
80
  "version": "0.1.15",
7
81
  "date": "2026-10-25",
@@ -90,8 +90,8 @@ export interface ActiveDisplay {
90
90
  title?: string;
91
91
  /** 产生该展示的会话 id(读取文件时按其专属工作目录解析,null = 共享工作区) */
92
92
  sessionId: number | null;
93
- /** 初始展示方式;文件管理器使用大弹窗,file_display 默认使用小窗 */
94
- presentation?: 'pip' | 'modal';
93
+ /** 初始展示方式;文件管理器预览复用其遮罩层,file_display 默认使用小窗 */
94
+ presentation?: 'pip' | 'modal' | 'workspace';
95
95
  }
96
96
 
97
97
  /** 启动阶段:加载中 / 数据就绪 / 核心数据加载失败(可重试) */
@@ -1409,9 +1409,13 @@
1409
1409
  font-size: 13px;
1410
1410
  }
1411
1411
  /* ---------- 文件展示弹窗(file_display) ---------- */
1412
+ .display-modal-root {
1413
+ z-index: 902;
1414
+ }
1412
1415
  .display-modal {
1413
1416
  width: min(880px, calc(100vw - 32px));
1414
1417
  height: min(82%, 720px);
1418
+ overflow: hidden;
1415
1419
  }
1416
1420
  .display-pip {
1417
1421
  position: fixed;
@@ -1769,21 +1773,10 @@
1769
1773
  align-items: center;
1770
1774
  gap: 4px;
1771
1775
  }
1772
- .ws-fm-open {
1773
- display: inline-flex;
1774
- align-items: center;
1775
- opacity: 0.55;
1776
- }
1777
1776
  .ws-fm-delete {
1778
1777
  width: 26px;
1779
1778
  height: 26px;
1780
1779
  color: var(--text-faint);
1781
- opacity: 0;
1782
- }
1783
- .ws-fm-row:hover .ws-fm-open,
1784
- .ws-fm-row:hover .ws-fm-delete,
1785
- .ws-fm-delete:focus-visible {
1786
- opacity: 1;
1787
1780
  }
1788
1781
  .ws-fm-delete:hover {
1789
1782
  color: var(--danger);
@@ -1799,6 +1792,20 @@
1799
1792
  gap: 10px;
1800
1793
  flex: 1;
1801
1794
  }
1795
+ .ws-fm-delete-layer {
1796
+ position: fixed;
1797
+ inset: 0;
1798
+ z-index: 902;
1799
+ display: grid;
1800
+ place-items: center;
1801
+ padding: 16px;
1802
+ pointer-events: auto;
1803
+ }
1804
+ .ws-fm-delete-dialog {
1805
+ width: min(420px, 100%);
1806
+ background: var(--surface);
1807
+ box-shadow: var(--shadow-lg);
1808
+ }
1802
1809
 
1803
1810
  /* ---------- 版本记录(设置 → 关于) ---------- */
1804
1811
  .settings-about-section {