feat: add workspace session list command

This commit is contained in:
xmanrui 2026-08-18 01:38:18 +08:00
parent c51334ebc3
commit 28381468b3
17 changed files with 989 additions and 122 deletions

View file

@ -16,6 +16,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -9,6 +9,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@ -229,6 +270,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning(options);
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30_000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);

View file

@ -15,6 +15,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -11,6 +11,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function messageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -210,6 +251,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning();
const workspaceList = await this.rpc('workspace.list', {}, 30000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -8,6 +8,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -417,20 +417,21 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
}
};
}
if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
if ((property === 'listWorkspaces' || property === 'listWorkspaceSessions')
&& typeof target[property] === 'function') {
return async (...args) => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const paths = await target.listWorkspaces(...args);
const result = await target[property](...args);
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return paths;
return result;
};
}
if (property === 'switchWorkspace') {

View file

@ -102,6 +102,7 @@ export class TextHarnessBridge {
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n'));

View file

@ -1,10 +1,19 @@
import { stat } from 'node:fs/promises';
import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
const SESSION_LIST_COMMAND = /^\/sessionlist(?:\s+([\s\S]+))?$/i;
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
const SESSION_LIST_USAGE = [
'用法:',
'/sessionlist 列出当前工作区会话',
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
'/sessionlist 工作区绝对路径 列出指定工作区会话',
].join('\n');
function commandResult(message, messages = [message]) {
return { handled: true, message, messages };
@ -12,20 +21,66 @@ function commandResult(message, messages = [message]) {
function normalizedWorkspacePath(value) {
if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
|| !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
|| !isAbsolute(value) || UNSAFE_DISPLAY_TEXT.test(value)) return null;
return resolve(value);
}
function safeDisplayText(value) {
if (typeof value !== 'string') return '';
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
}
async function existingWorkspacePaths(values) {
const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
const checked = await Promise.all(unique.map(async (workspace) => {
const checked = await Promise.all(values.map(async (value) => {
const workspace = normalizedWorkspacePath(value);
if (!workspace) return null;
try {
return (await stat(workspace)).isDirectory() ? workspace : null;
if (!(await stat(workspace)).isDirectory()) return null;
return normalizedWorkspacePath(await realpath(workspace));
} catch {
return null;
}
}));
return checked.filter(Boolean);
return [...new Set(checked.filter(Boolean))];
}
async function selectedWorkspacePath(value) {
if (typeof value !== 'string' || !isAbsolute(value.trim())) {
return { error: `工作区必须是绝对路径。\n${SESSION_LIST_USAGE}` };
}
const workspace = normalizedWorkspacePath(value.trim());
if (!workspace) {
return { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
}
let info;
try {
info = await stat(workspace);
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
if (!info.isDirectory()) {
return { error: `工作区路径必须指向一个目录。\n${SESSION_LIST_USAGE}` };
}
try {
const canonical = normalizedWorkspacePath(await realpath(workspace));
return canonical
? { workspace: canonical }
: { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
}
async function workspacePathSnapshot(harness) {
const listed = await harness.listWorkspaces();
const currentValue = typeof harness?.currentWorkspace === 'function'
? harness.currentWorkspace()
: null;
const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
const paths = [...new Set([...(current ? [current] : []), ...registered])];
harness.assertWorkspaceScope?.();
return { current: current ?? null, paths };
}
export function splitWorkspaceCommandMessage(message) {
@ -56,15 +111,7 @@ async function runWorkspaceListCommand(match, harness) {
return commandResult('当前机器人暂不支持列出工作区。');
}
try {
const listed = await harness.listWorkspaces();
const current = typeof harness.currentWorkspace === 'function'
? normalizedWorkspacePath(harness.currentWorkspace())
: null;
const paths = await existingWorkspacePaths([
...(current ? [current] : []),
...(Array.isArray(listed) ? listed : []),
]);
harness.assertWorkspaceScope?.();
const { current, paths } = await workspacePathSnapshot(harness);
if (paths.length === 0) {
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
}
@ -75,6 +122,7 @@ async function runWorkspaceListCommand(match, harness) {
)),
'',
'切换用法:/workspace 工作区绝对路径',
'查看会话:/sessionlist 工作区序号或绝对路径',
];
const message = lines.join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message));
@ -86,9 +134,80 @@ async function runWorkspaceListCommand(match, harness) {
}
}
async function resolveSessionListWorkspace(selector, harness) {
if (!selector) {
if (typeof harness?.currentWorkspace !== 'function') {
return { error: '当前机器人没有可用的工作区。' };
}
const selected = await selectedWorkspacePath(harness.currentWorkspace());
harness.assertWorkspaceScope?.();
return selected;
}
if (/^\d+$/u.test(selector)) {
if (typeof harness?.listWorkspaces !== 'function') {
return { error: '当前机器人暂不支持按序号选择工作区。' };
}
const { paths } = await workspacePathSnapshot(harness);
const position = Number(selector);
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
return { error: '工作区序号不存在,请先执行 /workspacelist。' };
}
return { workspace: paths[position - 1] };
}
const selected = await selectedWorkspacePath(selector);
harness.assertWorkspaceScope?.();
return selected;
}
function sessionListMessage(workspace, sessions) {
const rows = sessions.map((session) => {
const sessionId = safeDisplayText(session?.sessionId);
if (!sessionId) throw new TypeError('Harness returned an invalid session id');
const title = session?.summaryAvailable === false
? '标题暂不可用'
: safeDisplayText(session?.title) || '暂无标题';
return `${title}${session?.archived === true ? '(已归档)' : ''}\n ID: ${sessionId}`;
});
if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`;
return [
`工作区:${workspace}`,
`会话(${rows.length}):`,
'',
...rows.map((row, index) => `${index + 1}. ${row}`),
].join('\n');
}
async function runSessionListCommand(match, harness) {
if (typeof harness?.listWorkspaceSessions !== 'function') {
return commandResult('当前机器人暂不支持列出工作区会话。');
}
const selector = match[1]?.trim() ?? '';
try {
const resolved = await resolveSessionListWorkspace(selector, harness);
if (resolved.error) return commandResult(resolved.error);
const listed = await harness.listWorkspaceSessions(resolved.workspace);
if (!listed || !Array.isArray(listed.sessions)) {
throw new TypeError('Harness returned an invalid workspace session list');
}
harness.assertWorkspaceScope?.();
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
const message = sessionListMessage(workspace, listed.sessions);
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {
if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区会话。');
}
return commandResult('暂时无法获取工作区会话列表,请稍后重试。');
}
}
export async function runWorkspaceCommand(text, harness) {
if (typeof text !== 'string') return null;
const command = text.trim();
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
const match = WORKSPACE_COMMAND.exec(command);

View file

@ -9,6 +9,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -11,6 +11,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function assistantMessageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -200,6 +241,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning();
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30_000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -13,6 +13,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');