dsh-any-agent 0.3.1 → 0.3.3

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/src/mcp.mjs CHANGED
@@ -39,7 +39,8 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
39
39
  - cursor —— dsh_read 的增量游标,用来观察某个会话当前吐出的内容。
40
40
 
41
41
  【标准流程】
42
- 1. dsh_start(cwd, ...) 建会话。cwd 必须是**已存在**的绝对路径。
42
+ 1. dsh_start(cwd?, ...) 建会话。cwd 省略时自动用客户端当前打开的项目目录
43
+ (MCP roots);给了就必须是**已存在**的绝对路径,别传家目录本身。
43
44
  2. dsh_send(conversation_id, prompt) **默认不阻塞**:立刻返回 run_id + sentinel_file。
44
45
  想原地等结果才传 wait=true。
45
46
  3. 之后按需:
@@ -47,6 +48,7 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
47
48
  dsh_read(conversation_id, cursor) 中途看它当前吐出的内容(增量)
48
49
  dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果
49
50
  4. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
51
+ (会话空闲时 interject 等价于 wait=true,会阻塞到回合结束)
50
52
  5. dsh_release 交还会话(释放写锁,之后仍可 resume)
51
53
  6. dsh_takeover 写锁被别人占着时的**抢占**(见下)
52
54
 
@@ -85,9 +87,12 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
85
87
  - 会话在忙时**不要**再 dsh_send:想改口用 dsh_interject,想停用 dsh_interrupt,
86
88
  想把话留到本轮结束后说用 dsh_interject(mode='queue')。
87
89
  - 思考内容**默认隐藏**(只返回字数/耗时统计)。确实需要时传 reasoning='full',但很占上下文。
88
- - 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}(read-only 一律 auto-deny)。分析不信任样本(恶意软件、来路不明 dump)时用
90
+ - 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。**read-only 强制 auto-deny**:传 auto-allow/ask 会被直接拒绝(否则 DSH 的 sandbox 提权会被放行)。分析不信任样本(恶意软件、来路不明 dump)时用
89
91
  read-only:沙箱只读 + 审批一律拒绝。
90
92
  - 思考深度默认 max;想省钱可在 dsh_start 传 reasoning_effort 调低。
93
+ - 隐私边界:本服务自己的日志、哨兵、注册表都不写思考正文(哨兵只留思考统计);但 **DSH 自己的会话日志
94
+ ~/.dsh/sessions/** 会保存 reasoning,reasoning=hide 也照存** —— 分析敏感数据时要把它算进去。
95
+ - 异步哨兵文件保存最终正文与工具输入(常见密钥字面量已脱敏),默认保留 7 天;消费后建议自行删除。
91
96
  - 想并行:开多个会话,dsh_list(only_running=true) 看谁在跑,再轮流 dsh_read。
92
97
 
93
98
  【不要做】
@@ -96,10 +101,70 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
96
101
  - 不要把 DSH 内部的工具名(read/pwsh/glob 等)当成本服务的工具——本服务只有 dsh_* 这些。
97
102
  - 不要假设回合秒回。长任务请走 wait=false + 事后验收。`;
98
103
 
104
+ /**
105
+ * 把 MCP root 的 file:// URI 转成本地路径。
106
+ * 只接受 file 协议;其它(http/内嵌工作区等)一律当作“用不了”,
107
+ * 宁可退回要求显式 cwd,也不要把一个奇怪的字符串当成目录。
108
+ */
109
+ function fileURLToPathSafe(uri) {
110
+ try {
111
+ if (!uri.startsWith('file:')) return null;
112
+ return fileURLToPath(uri);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
99
118
  export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHandlers(hub), log }) {
100
119
  const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
101
120
  let buffer = '';
102
121
  let initialized = false;
122
+ let clientCapabilities = {};
123
+ let rootsSeq = 0;
124
+ const pendingRoots = new Map();
125
+ /** 客户端当前打开的项目目录(来自 MCP roots)。null 表示还没有/不支持。 */
126
+ let clientRoots = null;
127
+ let rootsRequested = false;
128
+
129
+ /**
130
+ * 主动向客户端要一次 roots。
131
+ * 这不是 JSON-RPC 的“请求-响应”对称用法:MCP 的服务端可以反向发请求,
132
+ * 客户端用同一个 stdout 回响应。所以这里带 id 发出去,再等 onData 收到
133
+ * 对应 id 的消息来完成 promise。超时/不支持都必须安静降级 —— roots 只是
134
+ * 一个更聪明的默认值,拿不到就让调用方显式传 cwd。
135
+ */
136
+ function requestRoots(timeoutMs = 1500) {
137
+ if (rootsRequested) return Promise.resolve(clientRoots);
138
+ rootsRequested = true;
139
+ const caps = clientCapabilities?.roots;
140
+ if (!caps) return Promise.resolve(null);
141
+ const id = 'roots-' + (rootsSeq++);
142
+ return new Promise((resolve) => {
143
+ const timer = setTimeout(() => {
144
+ pendingRoots.delete(id);
145
+ resolve(null);
146
+ }, timeoutMs);
147
+ timer.unref?.();
148
+ pendingRoots.set(id, (roots) => {
149
+ clearTimeout(timer);
150
+ clientRoots = roots;
151
+ resolve(roots);
152
+ });
153
+ write({ jsonrpc: '2.0', id, method: 'roots/list', params: {} });
154
+ });
155
+ }
156
+
157
+ /** 从 roots 里挑一个最像“项目目录”的路径。 */
158
+ function pickRoot(roots) {
159
+ if (!Array.isArray(roots) || !roots.length) return null;
160
+ const uris = roots.map((r) => r && typeof r.uri === 'string' ? r.uri : null).filter(Boolean);
161
+ if (!uris.length) return null;
162
+ for (const uri of uris) {
163
+ const asPath = fileURLToPathSafe(uri);
164
+ if (asPath) return asPath;
165
+ }
166
+ return null;
167
+ }
103
168
 
104
169
  const ok = (id, result) => write({ jsonrpc: '2.0', id, result });
105
170
  const fail = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } });
@@ -107,6 +172,14 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
107
172
  async function handle(msg) {
108
173
  const { id, method, params } = msg;
109
174
 
175
+ // 客户端对我们反向请求(roots/list)的响应:没有 method,只有 result/error。
176
+ if (method === undefined && pendingRoots.has(id)) {
177
+ const done = pendingRoots.get(id);
178
+ pendingRoots.delete(id);
179
+ done(msg.error ? [] : (msg.result?.roots ?? []));
180
+ return;
181
+ }
182
+
110
183
  // 通知(无 id):不需要回包
111
184
  if (id === undefined) {
112
185
  if (method === 'notifications/initialized') initialized = true;
@@ -116,6 +189,11 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
116
189
  switch (method) {
117
190
  case 'initialize': {
118
191
  const requested = params?.protocolVersion;
192
+ // 记住客户端声明了哪些能力:有 roots 的客户端(Claude Code / Cursor /
193
+ // VS Code 等)会告诉我们它当前打开的项目目录,那是 dsh_start 最合理的
194
+ // 默认 cwd —— 用户在客户端里看到哪个项目,就在这里干活。
195
+ const caps = params?.capabilities;
196
+ clientCapabilities = caps && typeof caps === 'object' ? caps : {};
119
197
  ok(id, {
120
198
  protocolVersion: typeof requested === 'string' ? requested : FALLBACK_PROTOCOL,
121
199
  capabilities: { tools: { listChanged: false } },
@@ -124,6 +202,11 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
124
202
  });
125
203
  return;
126
204
  }
205
+ case 'roots/list': {
206
+ // 有的客户端会主动问;我们不需要“暴露自己的根”,回空即可。
207
+ ok(id, { roots: [] });
208
+ return;
209
+ }
127
210
  case 'ping':
128
211
  ok(id, {});
129
212
  return;
@@ -190,8 +273,13 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
190
273
  process.stdin.on('end', () => log?.debug('[mcp] stdin 结束'));
191
274
  return this;
192
275
  },
276
+ /** 客户端当前打开的项目目录;不支持 roots 的客户端返回 null。 */
277
+ async projectDir() {
278
+ const roots = await requestRoots();
279
+ return pickRoot(roots);
280
+ },
193
281
  get initialized() {
194
282
  return initialized;
195
283
  },
196
284
  };
197
- }
285
+ }
package/src/tools.mjs CHANGED
@@ -34,7 +34,13 @@ export const TOOL_DEFS = [
34
34
  '返回 conversation_id 与对话标题。建好后用 dsh_send 派活。',
35
35
  inputSchema: S(
36
36
  {
37
- cwd: { type: 'string', description: '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' },
37
+ cwd: {
38
+ type: 'string',
39
+ description:
40
+ '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' +
41
+ '**省略时自动用客户端当前打开的项目目录**(MCP roots),这样客户端里看到哪个项目就在这里干活;' +
42
+ '客户端不支持 roots、或 roots 为空时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
43
+ },
38
44
  permission: {
39
45
  type: 'string',
40
46
  enum: PERMISSION_TIERS,
@@ -46,9 +52,16 @@ export const TOOL_DEFS = [
46
52
  on_approval: {
47
53
  type: 'string',
48
54
  enum: APPROVAL_POLICIES,
49
- description: `审批处理策略。默认 read-only → auto-deny,其余 → ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
55
+ description:
56
+ `审批处理策略。**read-only 强制 auto-deny**(传 auto-allow/ask 会被直接拒绝,否则会放行 DSH 的 sandbox 提权);` +
57
+ `其余档位默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
58
+ },
59
+ title_hint: {
60
+ type: 'string',
61
+ description:
62
+ '可读的会话标签。注意 DSH 自己的标题是首条消息的截断前缀(约 13–15 字),会优先显示;' +
63
+ '这里给的值不会被覆盖,而是在 dsh_get / dsh_start 里作为「标题提示」一并返回。',
50
64
  },
51
- title_hint: { type: 'string', description: '可选的标题提示(DSH 的标题由首条消息自动生成,这里只是兜底显示)。' },
52
65
  reasoning_effort: {
53
66
  type: 'string',
54
67
  enum: REASONING_EFFORTS,
@@ -57,7 +70,8 @@ export const TOOL_DEFS = [
57
70
  provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
58
71
  model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
59
72
  },
60
- ['cwd'],
73
+ // cwd 不再是必填:有 MCP roots 的客户端会自动带上当前项目目录。
74
+ [],
61
75
  ),
62
76
  },
63
77
  {
@@ -91,7 +105,7 @@ export const TOOL_DEFS = [
91
105
  timeout_ms: {
92
106
  type: 'number',
93
107
  description:
94
- '仅 wait=true 时的**等待**上限(毫秒),默认 30 分钟;0 = 一直等。到期不会取消回合,只会转成后台并给你 run_id。',
108
+ '仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
95
109
  },
96
110
  },
97
111
  ['conversation_id', 'prompt'],
@@ -125,7 +139,8 @@ export const TOOL_DEFS = [
125
139
  '不必等回合结束。典型用法:dsh_list(only_running) 找到在跑的会话 → 反复 dsh_read 并回传上次的 cursor,' +
126
140
  '就能像切换窗口一样轮流观察多个会话。' +
127
141
  '输出缓冲**只在内存里且有界**(环形,满则丢最旧),服务/进程退出即消失;' +
128
- '**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。',
142
+ '**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。' +
143
+ 'cursor 是**修订号**:同一条目被追加文本后会再次返回,调用方按 `seq` 用它替换全文(不要重复累加)。',
129
144
  inputSchema: S(
130
145
  {
131
146
  conversation_id: CONV_ID,
@@ -141,7 +156,8 @@ export const TOOL_DEFS = [
141
156
  description:
142
157
  '查看单个会话的详情;带 run_id 时**取那一次派活的最终结果**(异步模式的验收入口)。' +
143
158
  '不传 run_id 则取最近一次回合。返回:标题、工作区、权限档、状态、轮数、token 用量、' +
144
- '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。',
159
+ '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。' +
160
+ '**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后请改读哨兵文件(它落盘且扛重启)。',
145
161
  inputSchema: S(
146
162
  {
147
163
  conversation_id: CONV_ID,
@@ -155,7 +171,7 @@ export const TOOL_DEFS = [
155
171
  description:
156
172
  '插话:会话正在跑某个回合时往里补一句话。两种模式——' +
157
173
  'interject(默认)打断当前回合并立刻改口;queue 不打断,等本轮自然结束后紧接着说。' +
158
- '会话空闲时等同于一次普通 dsh_send。' +
174
+ '会话空闲时按 **wait=true 的普通回合**执行(会阻塞到回合结束并返回完整结果,不是收据)。' +
159
175
  '说明:ACP 明确拒绝并发 prompt,所以做不到"让模型在同一个回合里即时改向";' +
160
176
  'interject 是它的实用等价物(取消 + 立刻开新回合,历史完整保留)。',
161
177
  inputSchema: S(
@@ -172,7 +188,7 @@ export const TOOL_DEFS = [
172
188
  enum: REASONING_MODES,
173
189
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
174
190
  },
175
- timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),默认 30 分钟;0 = 一直等。到期转后台,不取消回合。' },
191
+ timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。到期转后台,不取消回合。' },
176
192
  },
177
193
  ['conversation_id', 'message'],
178
194
  ),
@@ -210,8 +226,10 @@ export const TOOL_DEFS = [
210
226
  name: 'dsh_release',
211
227
  description:
212
228
  '释放会话占用的 DSH 进程(释放写锁,让用户能在自己的 DSH GUI 里打开同一个会话)。' +
213
- '会话本体留在磁盘上,之后仍可 resume;forget=true 才会从注册表里摘掉。' +
214
- '★ 用完了想一次性腾内存就传 all=true(默认"永不回收"下每个会话约 300-480MB,全放掉即立刻归零,之后随时 resume)。',
229
+ '会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
230
+ '(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
231
+ '★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
232
+ 'all=true 可立即全部释放,之后随时 resume)。',
215
233
  inputSchema: S(
216
234
  {
217
235
  conversation_id: CONV_ID,
@@ -319,11 +337,30 @@ function backgroundReceipt(hub, conv, res) {
319
337
  };
320
338
  }
321
339
 
322
- export function createHandlers(hub) {
340
+ /**
341
+ * @param {object} hub
342
+ * @param {{projectDir?: () => Promise<string|null>}} [opts]
343
+ * projectDir:客户端当前打开的项目目录(MCP roots)。不传 cwd 时用它兜底 ——
344
+ * 让「在客户端里看到哪个项目,就在哪个项目干活」成为默认行为,而不是让
345
+ * 调用方每次都自己拼绝对路径(拼错了就落到家目录,撞上 ACL 沙箱那个坑)。
346
+ */
347
+ export function createHandlers(hub, opts = {}) {
348
+ const projectDir = typeof opts.projectDir === 'function' ? opts.projectDir : null;
349
+
323
350
  return {
324
351
  async dsh_start(args) {
352
+ let cwd = args.cwd;
353
+ let cwdSource = cwd ? 'explicit' : null;
354
+ if (!cwd && projectDir) {
355
+ // 客户端声明了 roots 才有;拿不到就照旧要求显式传,不猜。
356
+ const fromClient = await projectDir();
357
+ if (fromClient) {
358
+ cwd = fromClient;
359
+ cwdSource = 'client-root';
360
+ }
361
+ }
325
362
  const conv = await hub.create({
326
- cwd: args.cwd,
363
+ cwd,
327
364
  permission: args.permission ?? DEFAULT_PERMISSION,
328
365
  onApproval: args.on_approval,
329
366
  titleHint: args.title_hint,
@@ -336,17 +373,31 @@ export function createHandlers(hub) {
336
373
  const lines = [
337
374
  '已创建会话。',
338
375
  `conversation_id: ${snap.conversation_id}`,
339
- `工作区: ${snap.cwd}`,
376
+ `工作区: ${snap.cwd}` + (cwdSource === 'client-root' ? '(取自客户端当前打开的项目)' : ''),
340
377
  `权限档: ${snap.permission}(审批策略 ${snap.on_approval})`,
341
378
  `思考深度: ${snap.reasoning_effort}`,
342
- `对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}`,
379
+ `对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}` +
380
+ (snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
343
381
  `工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
344
382
  ];
345
- if (snap.config_failed?.length) lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
383
+ if (snap.config_failed?.length) {
384
+ lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
385
+ if (snap.config_failed.some((f) => String(f).startsWith('模型='))) {
386
+ lines.push(
387
+ '⚠️ 指定的 provider/model 无效:本会话已回退到 profile 默认模型,' +
388
+ '后续回合**不会**使用你指定的模型。',
389
+ );
390
+ }
391
+ }
346
392
  return { structured: snap, text: lines.join('\n') };
347
393
  },
348
394
 
349
395
  async dsh_send(args) {
396
+ // 空/纯空白 prompt 会被 DSH 以 [-32602] 拒绝;默认 wait=false 下那会变成
397
+ // 一个“accepted 但注定失败”的收据(错误只异步出现在哨兵里)。这里同步拦下。
398
+ if (typeof args.prompt !== 'string' || args.prompt.trim() === '') {
399
+ throw new Error('prompt 不能为空:至少包含一个非空白字符');
400
+ }
350
401
  const conv = hub.get(args.conversation_id);
351
402
  const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
352
403
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
@@ -451,7 +502,9 @@ export function createHandlers(hub) {
451
502
  const snap = conv.snapshot();
452
503
  let run = null;
453
504
  const meta = [];
454
- meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)`);
505
+ // DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
506
+ meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)` +
507
+ (snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
455
508
  meta.push(`工作区: ${snap.cwd}`);
456
509
  meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
457
510
  meta.push(`权限档: ${snap.permission} / 审批 ${snap.on_approval}`);
@@ -497,6 +550,9 @@ export function createHandlers(hub) {
497
550
  },
498
551
 
499
552
  async dsh_interject(args) {
553
+ if (typeof args.message !== 'string' || args.message.trim() === '') {
554
+ throw new Error('message 不能为空:至少包含一个非空白字符');
555
+ }
500
556
  const conv = hub.get(args.conversation_id);
501
557
  const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
502
558
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
@@ -620,4 +676,4 @@ export function createHandlers(hub) {
620
676
  };
621
677
  },
622
678
  };
623
- }
679
+ }
package/src/workspace.mjs CHANGED
@@ -24,6 +24,8 @@ import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
24
24
 
25
25
  const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
26
26
  const SESSIONS_ROOT = join(DSH_HOME, 'sessions');
27
+ /** forget 墓碑目录(存 id+时间,不含内容);清掉测试会话时一并清掉其墓碑。 */
28
+ const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
27
29
 
28
30
  /** 路径比较用:Windows 下大小写不敏感,且忽略结尾分隔符。 */
29
31
  const normPath = (p) => String(p ?? '').replace(/[\\/]+$/, '').toLowerCase();
@@ -329,6 +331,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
329
331
  } catch (e) {
330
332
  v.error = e.message;
331
333
  }
334
+ // 会话文件都没了,墓碑也就没有意义:一起清掉,避免测试残留。
335
+ try {
336
+ rmSync(join(FORGOTTEN_DIR, `${v.id}.json`), { force: true });
337
+ } catch {
338
+ /* 墓碑不存在或删不掉都不影响会话清理 */
339
+ }
332
340
  }
333
341
  // 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
334
342
  for (const bucket of readdirSync(SESSIONS_ROOT)) {