feat: group option in new-conversation pane (join existing / create new workspace via mkdir route)

This commit is contained in:
Aisland
2026-08-18 23:27:16 +08:00
parent fa1a0b3866
commit 7b3388bfff
8 changed files with 273 additions and 12 deletions
+49 -8
View File
@@ -263,7 +263,7 @@ function findSidebar(start: HTMLElement | null): HTMLElement | null {
const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() }
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
let sessionBridge: { sessions: any; conversation: any; list: any } | null = null
let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null
/** 把文本送入指定会话:宿主同款寻址(binding(id).session.prompt / sendSession(会话面)),
* 插件是根级上下文,无作用域的 conversation.send 会报 requires a session scope,不可用。 */
@@ -299,8 +299,11 @@ async function promptIntoSession(sessionId: string, text: string): Promise<void>
throw new Error('no send path: session face unavailable')
}
/** 自定义窗口任务:新建一个专属会话,注入工作台背景 + 用户需求,并打开该会话 */
export async function createCustomSession(projectId: string, projectName: string, requirement: string): Promise<void> {
/** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */
export type NewSessionGroup = { kind: 'none' } | { kind: 'existing'; workspaceId: string } | { kind: 'new'; parent: string; name: string }
/** 自定义窗口任务:新建一个专属会话(可选指定分组),注入工作台背景 + 用户需求,并打开该会话 */
export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup): Promise<void> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = [
@@ -316,7 +319,30 @@ export async function createCustomSession(projectId: string, projectName: string
'4. 请基于以上背景,帮助用户完成这个窗口的自定义设计(例如产出可直接放入窗口的内容、页面或实现方案);',
' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
].join('\n')
const sessionId = await b.sessions.create({})
// 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区
let workspaceId: string | null = null
const g = group ?? { kind: 'none' as const }
if (g.kind === 'existing') {
workspaceId = g.workspaceId
} else if (g.kind === 'new') {
const ws = b.workspaces as any
if (!ws || typeof ws.create !== 'function') throw new Error('workspaces unavailable')
const parent = g.parent.replace(/[\\/]+$/, '')
const name = g.name.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
if (!parent || !name) throw new Error('invalid group path')
const full = parent + '\\' + name
try { await ws.createDirectory?.(parent, name) } catch { /* 宿主 browse 能力本机为 native 时不可用 */ }
try {
// 兜底:插件服务端 mkdir(父目录必须已存在,避免误建深层目录)
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: full }) })
const d = await r.json()
if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed')
} catch { /* 目录建不出来的错误最终由 workspace.create 暴露 */ }
const view = await ws.create({ path: full })
workspaceId = view?.workspaceId ?? view?.id ?? null
if (!workspaceId) throw new Error('workspace create failed')
}
const sessionId = await b.sessions.create(workspaceId ? { workspaceId } : {})
try { await b.sessions.open?.(sessionId) } catch {}
await promptIntoSession(sessionId, text)
}
@@ -499,7 +525,22 @@ function WorktableSection(props: any) {
} catch { return { groups: [], current: '' } }
},
sendToSession: (sessionId, projectName, requirement) => sendCustomToSession(sessionId, projectName, requirement),
submit: (projectId, projectName, requirement) => createCustomSession(projectId, projectName, requirement),
submit: (projectId, projectName, requirement, group) => createCustomSession(projectId, projectName, requirement, group),
// 分组(宿主工作区)数据源:读 ctx.workspaces 快照,供新建对话选分组
getWorkspaces: () => {
try {
const snap = sessionBridge?.workspaces?.list?.getSnapshot?.()
const items = snap?.items ?? []
return items.map((w: any) => ({
id: w.workspaceId ?? w.id,
title: w.title ?? '',
path: w.path ?? '',
sessionIds: Array.isArray(w.sessionIds) ? w.sessionIds : [],
}))
} catch { return [] }
},
createWorkspace: (path: string) => sessionBridge?.workspaces?.create?.({ path }),
createWorkspaceDir: (parent: string, name: string) => sessionBridge?.workspaces?.createDirectory?.(parent, name),
},
})
return () => setSplitEnv(null)
@@ -1410,12 +1451,12 @@ function WorktableSection(props: any) {
)
}
export const inject = ['slots', 'locale', 'sessions', 'conversation']
export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspaces']
export function apply(ctx: any) {
// 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级)
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null }
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text) } catch {}
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null }
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces } catch {}
ctx.effect(() => {
+16
View File
@@ -73,6 +73,14 @@ export const zh = {
'custom.done': '已新建专属对话',
'custom.doneHint': 'AI 正在新会话中为你设计这个窗口。关闭本标签即可返回窗口选择。',
'custom.fail': '创建对话失败',
'custom.group': '分组(工作区)',
'custom.groupNone': '未分组',
'custom.groupNew': '+ 新建一个分组…',
'custom.groupNewParent': '分组父目录',
'custom.groupNewName': '新分组名',
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
'custom.groupNeedPath': '请填写分组父目录和新分组名',
'pane.wip': '开发中(后续版本接入)',
'pane.explorerNoCwd': '当前会话没有工作目录',
'pane.openLater': '该类型暂不支持预览',
@@ -169,6 +177,14 @@ export const en = {
'custom.done': 'Conversation created',
'custom.doneHint': 'The AI is designing your pane in the new conversation. Close this tab to return to the pane picker.',
'custom.fail': 'Failed to create conversation',
'custom.group': 'Group (workspace)',
'custom.groupNone': 'Ungrouped',
'custom.groupNew': '+ Create a new group…',
'custom.groupNewParent': 'Group parent folder',
'custom.groupNewName': 'New group name',
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
'pane.wip': 'Coming in a later version',
'pane.explorerNoCwd': 'No working directory for the current session',
'pane.openLater': 'Preview not supported for this type',
+65 -4
View File
@@ -1299,19 +1299,29 @@ function CustomPane() {
const [mode, setMode] = useState<'new' | 'existing'>('new')
const [sessionGroups, setSessionGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
const [sessionId, setSessionId] = useState<string | null>(null)
// 分组(宿主工作区)三态:未分组 / 现有组 / 新建组
const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([])
const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none')
const [groupId, setGroupId] = useState<string | null>(null)
const [newGroupParent, setNewGroupParent] = useState('')
const [newGroupName, setNewGroupName] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const [fail, setFail] = useState('')
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话;默认分组 = 当前会话所在工作区
useEffect(() => {
const list = custom?.getProjects?.() ?? []
setProjects(list)
const cur = custom?.currentProjectId?.() ?? null
setProjectId(cur && list.some((p) => p.id === cur) ? cur : (list[0]?.id ?? null))
setWsGroups((custom?.getWorkspaces?.() ?? []).map((w) => ({ id: w.id, title: w.title, path: w.path })))
custom?.getSessions?.().then((res) => {
setSessionGroups(res.groups)
const flat = res.groups.flatMap((g) => g.sessions)
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null)
const curId = flat.find((s) => s.isCurrent)?.id
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null
if (home) { setGroupMode('existing'); setGroupId(home.id) }
}).catch(() => { setSessionGroups([]) })
}, [custom])
const submit = async () => {
@@ -1322,8 +1332,15 @@ function CustomPane() {
try {
const proj = projects.find((p) => p.id === projectId)
const pname = proj?.name ?? projectId
if (mode === 'new') await custom.submit(projectId, pname, text)
else {
if (mode === 'new') {
let group: any = { kind: 'none' }
if (groupMode === 'existing' && groupId) group = { kind: 'existing', workspaceId: groupId }
else if (groupMode === 'new') {
if (!newGroupParent.trim() || !newGroupName.trim()) { setFail(T('custom.groupNeedPath')); return }
group = { kind: 'new', parent: newGroupParent.trim(), name: newGroupName.trim() }
}
await custom.submit(projectId, pname, text, group)
} else {
if (!sessionId) return
await custom.sendToSession(sessionId, pname, text)
}
@@ -1385,10 +1402,54 @@ function CustomPane() {
onChange={setProjectId}
/>
</div>
{mode === 'new' && (
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.group')}</span>
<SelectPop
value={groupMode === 'none' ? '__none' : groupMode === 'new' ? '__new' : groupId}
groups={[{
title: '',
items: [
{ id: '__none', label: T('custom.groupNone') },
...wsGroups.map((w) => ({ id: w.id, label: w.title })),
{ id: '__new', label: T('custom.groupNew') },
],
}]}
placeholder={T('custom.group')}
onChange={(id) => {
if (id === '__none') setGroupMode('none')
else if (id === '__new') setGroupMode('new')
else { setGroupMode('existing'); setGroupId(id) }
}}
/>
</div>
)}
{mode === 'new' && groupMode === 'new' && (
<>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewParent')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewParentPh')}
value={newGroupParent}
onChange={(e) => setNewGroupParent(e.target.value)}
/>
</div>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewName')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewNamePh')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
</>
)}
<button
type="button"
className="dsh-wt_customSend"
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId)}
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId) || (mode === 'new' && groupMode === 'new' && (!newGroupParent.trim() || !newGroupName.trim()))}
onClick={submit}
>{busy ? '…' : (mode === 'new' ? T('custom.send') : T('custom.sendToSession'))}</button>
{fail && <p className="dsh-wt_customFail">{T('custom.fail')}:{fail}</p>}
+3
View File
@@ -201,6 +201,9 @@ export const css = [
'.dsh-wt_customModeBtnOn{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_customInput{width:100%;box-sizing:border-box;min-height:96px;padding:8px 10px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;outline:none;resize:vertical}',
'.dsh-wt_customInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
// 新建分组:父目录 / 名称单行输入
'.dsh-wt_customPathInput{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;outline:none}',
'.dsh-wt_customPathInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_customRow{display:flex;align-items:center;gap:8px}',
'.dsh-wt_customLabel{flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
'.dsh-wt_customSelect{flex:1;min-width:0;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;outline:none}',
+22
View File
@@ -320,6 +320,28 @@ export function apply(ctx: Context) {
},
})
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
webServer.register({
kind: 'exact',
path: '/api/worktable/mkdir',
handler: async (req: any, res: any) => {
try {
if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
const body = await readJsonBody(req)
const p = typeof body.path === 'string' ? body.path.trim() : ''
if (!p) { json(res, 400, { error: 'missing path' }); return }
const abs = pathResolve(p)
const fsx = await import('node:fs/promises')
const parent = abs.split(/[\\/]/).slice(0, -1).join('\\') || '\\'
try { await fsx.access(parent) } catch { json(res, 400, { error: 'parent not found' }); return }
await fsx.mkdir(abs)
json(res, 200, { ok: true, path: abs })
} catch (err: any) {
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
}
},
})
webServer.register({
kind: 'exact',
path: '/api/worktable/git',
@@ -681,3 +681,22 @@
listEntry running=true 轮次启动);functional-diag 全 20 STEP ERROR_COUNT: 0。
- 备注:纯客户端改动,F5 即生效;自测留下的一次性会话请用户手动删除。
## 补记(新建对话加分组选项:加入现有组 / 新建一个组)
- 用户反馈:新建对话只能落在未分组,无法加进已有项目组(如 Projects),也没法顺手建新组。
- 宿主能力勘察:sessions.create({workspaceId}) 指定分组建会话;ctx.workspaces 服务提供
list/create({path})/rename/delete/archiveSession 等;本机目录选择器为 native 模式,
createDirectory 需 browse 能力不可用 → 建组目录改走插件服务端 mkdir 路由。
- 实现:① 插件 inject 增 'workspaces',桥接 ctx.workspaces;② 新建模式加「分组」三态选择
(未分组 / 现有组列表 / +新建分组),新建组显示父目录+名称两个输入框;③ 默认分组 =
当前会话所在工作区(体验:新会话直接落在你正在用的组里);④ 服务端新增
POST /api/worktable/mkdir(父目录必须已存在,防误建深层目录),客户端 create 前调用兜底;
⑤ createCustomSession 按分组解析 workspaceId 后 sessions.create({workspaceId})。
- 验证:STEP20 扩展断言全过——newSelectCount=2、groupDefaultValue=Projects▾(自动选中当前组)、
groupItemCount=4、groupHasNone/groupHasNew=true、newGroupInputs=2、sendDisabledNewGroup=true、
groupInputsAfterNone=0;ERRORS_COUNT: 0。
- 待办:mkdir 为服务端路由,需用户重启 dsh web 后跑 group-cycle-test.cjs 全周期自测
(建目录→注册分组→按组建会话→验证→归档会话+删分组+清目录,无残留)。
- 备注:加入现有组纯客户端(重启前即可用);新建组依赖 mkdir 路由(重启后可用)。
+29
View File
@@ -656,6 +656,35 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
out.defaultIsCurrent=false;
var send=vis('.dsh-wt_customSend');
out.sendDisabled=send?send.disabled:null;
// 新建模式的分组选择:项目选择器之后应有分组选择器;点开应含 未分组/现有组/新建分组
var nbtns=[].slice.call(document.querySelectorAll('.dsh-wt_selectBtn')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');});
out.newSelectCount=nbtns.length;
var gbtn=nbtns[1];
out.groupSelectPresent=!!gbtn;
out.groupDefaultValue=gbtn?String(gbtn.textContent).trim():null;
if(gbtn){ gbtn.click(); }
await new Promise(function(r){setTimeout(r,250)});
var glist=[].slice.call(document.querySelectorAll('.dsh-wt_selectList')).find(function(el){return (getComputedStyle(el).visibility!=='hidden');});
out.groupListOpen=!!glist;
out.groupItemCount=glist?glist.querySelectorAll('.dsh-wt_selectItem').length:0;
var gitems=glist?[].slice.call(glist.querySelectorAll('.dsh-wt_selectItem')):[];
out.groupHasNone=gitems.some(function(el){return String(el.textContent).indexOf('\u672a\u5206\u7ec4')>=0});
out.groupHasNew=gitems.some(function(el){return String(el.textContent).indexOf('\u65b0\u5efa')>=0});
var newItem=gitems.find(function(el){return String(el.textContent).indexOf('\u65b0\u5efa')>=0});
if(newItem){ newItem.click(); }
await new Promise(function(r){setTimeout(r,250)});
out.newGroupInputs=[].slice.call(document.querySelectorAll('.dsh-wt_customPathInput')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');}).length;
var send2=vis('.dsh-wt_customSend');
out.sendDisabledNewGroup=send2?send2.disabled:null;
// 收尾:选回「未分组」,输入框应消失
var nbtns2=[].slice.call(document.querySelectorAll('.dsh-wt_selectBtn')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');});
if(nbtns2[1]){ nbtns2[1].click(); }
await new Promise(function(r){setTimeout(r,250)});
var glist2=[].slice.call(document.querySelectorAll('.dsh-wt_selectList')).find(function(el){return (getComputedStyle(el).visibility!=='hidden');});
var noneItem=glist2?[].slice.call(glist2.querySelectorAll('.dsh-wt_selectItem')).find(function(el){return String(el.textContent).indexOf('\u672a\u5206\u7ec4')>=0}):null;
if(noneItem){ noneItem.click(); }
await new Promise(function(r){setTimeout(r,250)});
out.groupInputsAfterNone=[].slice.call(document.querySelectorAll('.dsh-wt_customPathInput')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');}).length;
// 切到「发送到会话」模式:出现自制会话下拉,点开看分组与当前标记
var modeBtns=[].slice.call(document.querySelectorAll('.dsh-wt_customModeBtn')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');});
if(modeBtns[1]){ modeBtns[1].click(); }
+70
View File
@@ -0,0 +1,70 @@
// group-cycle-test.cjs — 分组全周期自测(可清理):新建目录 → 注册工作区 → 按 workspaceId 建会话 → 验证 → 删除工作区 + 归档会话
const http = require('http');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const PORT = 9339;
const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--window-size=1440,900', '--force-device-scale-factor=1',
'--remote-debugging-port=' + PORT,
'--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-grouptest',
'about:blank',
], { stdio: 'ignore' });
const getJSON = (p) => new Promise((res, rej) => {
http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
let list = null;
for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); }
const target = list.find((t) => t.type === 'page') || list[0];
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text };
return r.result && r.result.result ? r.result.result.value : undefined;
};
let ready = null;
for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorkspaces && !!window.__dshSessions"); if (ready === true) break; }
console.log('READY:', JSON.stringify(ready));
const step = await evaluate(`(async function(){
var out = {};
var W = window.__dshWorkspaces;
var S = window.__dshSessions;
var parent = 'C:\\\\Users\\\\SJL\\\\AppData\\\\Local\\\\Temp';
var name = 'wt-grouptest-' + Date.now();
out.parent = parent;
out.name = name;
try {
out.dirCreated = await W.createDirectory(parent, name);
var view = await W.create({ path: out.dirCreated });
out.workspaceId = view && (view.workspaceId || view.id);
out.workspaceTitle = view && view.title;
var newId = await S.create({ workspaceId: out.workspaceId });
out.sessionId = newId;
await new Promise(function(r){ setTimeout(r, 1200); });
var wsnap = W.list.getSnapshot();
var row = (wsnap.items || []).find(function(w){ return (w.workspaceId || w.id) === out.workspaceId; });
out.wsInList = !!row;
out.sessionInWs = row ? (row.sessionIds || []).indexOf(newId) >= 0 : false;
var ssnap = S.list.getSnapshot();
out.sessionListed = !!(ssnap.byId && ssnap.byId[newId]);
out.sessionCwd = ssnap.byId && ssnap.byId[newId] ? ssnap.byId[newId].cwd : null;
// 清理:归档会话 + 删除工作区
try { await W.archiveSession(newId); out.archived = true; } catch(e) { out.archiveErr = e && e.message; }
try { await W.delete(out.workspaceId); out.wsDeleted = true; } catch(e) { out.deleteErr = e && e.message; }
await new Promise(function(r){ setTimeout(r, 1200); });
var wsnap2 = W.list.getSnapshot();
out.wsGone = !(wsnap2.items || []).some(function(w){ return (w.workspaceId || w.id) === out.workspaceId; });
} catch(e) { out.fatal = e && e.message; }
return JSON.stringify(out);
})()`);
console.log('GROUPCYCLE:', JSON.stringify(step));
ws.close(); proc.kill(); process.exit(0);
})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });