/** * Host half: move one conversation (Session) from its current Workspace to * another registered Workspace. * * Why this is more than a sidebar edit * ------------------------------------ * A Workspace owns a canonical directory, and a Session belongs to a Workspace * because its **log header** records that directory as `cwd`. The registry * (`ctx.workspaceRegistry`) indexes every stored header at startup and keeps a * `sessionId -> canonical cwd` map; a Session whose header cwd does not match a * Workspace path is filtered out of that Workspace's membership. * * The session-persistence backend (`dsh-session-persistence-jsonl`) additionally * derives the on-disk location from the header: `/----/ * /session.vN.jsonl.zstd`, and it validates on open that the * stored path matches the path the header identifies. Moving a conversation * therefore has to move four pieces of durable state together: * * 1. the session directory, into the target Workspace's project directory; * 2. the first (header) record of the current log generation, whose `cwd` is * rewritten to the target Workspace path; * 3. the workspace registry accounting (detach from the old Workspace, * attach to the new one), so the sidebar regroups immediately and stays * correct across restarts; * 4. (best effort) a `working-directory/change` projection event, so a * conversation that had explicitly changed directory continues in the new * Workspace's directory after it is reopened. * * The header lives in the first Zstandard frame of the log, which is an * independently decodable frame; this plugin replaces exactly that frame and * keeps every following byte untouched, so committed events are never rewritten. * * A Session that is currently open in this process (live in `ctx.sessions`) is * refused: its handle still appends through the old header, so relocating the * log under it would split the log across two project directories. * * Endpoints: * POST /api/dsh-session-move { sessionId, workspaceId, dryRun? } * * Agent tool: `move_session_to_workspace`. */ import { zstdCompressSync, zstdDecompressSync, constants as zlibConstants } from 'node:zlib' import { readdirSync, statSync } from 'node:fs' import { mkdir, open, readFile, readdir, realpath, rename } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' export const name = 'session-move' export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence'] const ROUTE = '/api/dsh-session-move' const TOOL_NAME = 'move_session_to_workspace' /** Staging directory under `$DSH_HOME`, deliberately outside the sessions root. */ const STAGING_DIRNAME = '.session-move-staging' /** Zstandard frame magic (`0xFD2FB528` as an unsigned little-endian integer). */ const ZSTD_MAGIC = 4247762216 const CHECKSUM_OPTIONS = { params: { [zlibConstants.ZSTD_c_checksumFlag]: 1 } } /** The current generation name; older generations share the `session[.vN].jsonl` shape. */ const GENERATION_FILE = /^session(?:\.v(0|[1-9][0-9]*))?\.jsonl(\.zstd)?$/ /** Cap for the projection-event scan; above it the move skips the extra event. */ const PROJECTION_SCAN_LIMIT_BYTES = 32 * 1024 * 1024 // ---------------------------------------------------------------- paths /** `$DSH_HOME`, matching the rest of the Harness (`~/.dsh` by default). */ function dshHome() { const fromEnv = process.env.DSH_HOME return typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh') } /** The configured JSONL persistence root (`dshHomePath('sessions')` in dsh-base). */ function sessionsRoot() { return join(dshHome(), 'sessions') } function stagingRoot() { return join(dshHome(), STAGING_DIRNAME) } /** * Encode an arbitrary Session id as one safe path segment. Copied from the * session-persistence backend (`encodeSegment`) so this plugin derives exactly * the directory the backend itself would use. */ function encodeSegment(raw) { if (raw.length === 0) throw new Error('cannot encode an empty path segment') if (raw === '.') return '~002E' if (raw === '..') return '~002E~002E' let out = '' for (let i = 0; i < raw.length; i += 1) { const code = raw.charCodeAt(i) const ch = String.fromCharCode(code) if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch else out += `~${code.toString(16).toUpperCase().padStart(4, '0')}` } return out } /** * The readable project-directory key for a canonical path. Copied from the * session-persistence backend (`projectKey`): separators become `-`, unsafe * code units use `~XXXX`, the result is bounded and wrapped in `--`. */ function projectKey(cwd) { if (cwd.length === 0) throw new Error('cannot encode an empty project path') let readable = '' let separatorRun = false for (let i = 0; i < cwd.length; i += 1) { const code = cwd.charCodeAt(i) const ch = String.fromCharCode(code) if (ch === '/' || ch === '\\' || ch === ':') { if (!separatorRun) readable += '-' separatorRun = true } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch separatorRun = false } else { readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}` separatorRun = false } } return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--` } function isDirectory(path) { try { return statSync(path).isDirectory() } catch { return false } } // ------------------------------------------------------- zstd frame surgery /** * Locate complete Zstandard frames without decompressing their blocks. Copied * from the backend's `scanZstdFrames`; only the first (header) frame is needed. */ function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) { const frames = [] let offset = 0 while (offset < buffer.length) { const start = offset if (buffer.length - offset < 4) return { frames, tornStart: start } if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`) } offset += 4 if (offset === buffer.length) return { frames, tornStart: start } const descriptor = buffer.readUInt8(offset) offset += 1 if ((descriptor & 24) !== 0) { throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`) } const contentSizeFlag = descriptor >>> 6 const singleSegment = (descriptor & 32) !== 0 const checksum = (descriptor & 4) !== 0 const dictionaryFlag = descriptor & 3 const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } offset += remainingHeaderBytes for (;;) { if (buffer.length - offset < 3) return { frames, tornStart: start } const blockHeader = buffer.readUIntLE(offset, 3) offset += 3 const lastBlock = (blockHeader & 1) !== 0 const blockType = (blockHeader >>> 1) & 3 const blockSize = blockHeader >>> 3 if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`) const payloadBytes = blockType === 1 ? 1 : blockSize if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } offset += payloadBytes if (lastBlock) break } if (checksum) { if (buffer.length - offset < 4) return { frames, tornStart: start } offset += 4 } frames.push({ start, end: offset }) if (frames.length === maxFrames) return { frames } } return { frames } } /** Read the header record out of a stored log, plus the byte length of its frame. */ function inspectHeader(buffer, compressed) { if (compressed) { const { frames } = scanZstdFrames(buffer, 1) const first = frames[0] if (first === undefined) throw new Error('corrupt session log: no complete Zstandard frame') const plaintext = zstdDecompressSync(buffer.subarray(first.start, first.end)) if (plaintext.length === 0 || plaintext[plaintext.length - 1] !== 10) { throw new Error('corrupt session log: first frame is not exactly one header line') } const line = plaintext.subarray(0, plaintext.length - 1).toString('utf8') return { header: JSON.parse(line), headerEnd: first.end } } const newline = buffer.indexOf(10) if (newline < 0) throw new Error('corrupt session log: header line is not newline-terminated') const line = buffer.subarray(0, newline).toString('utf8') return { header: JSON.parse(line), headerEnd: newline + 1 } } /** The current (highest) generation file inside one session directory. */ function findCurrentLogFile(dir) { let names = [] try { names = readdirSync(dir) } catch { return undefined } let best for (const fileName of names) { const match = GENERATION_FILE.exec(fileName) if (match === null) continue const version = match[1] === undefined ? 0 : Number(match[1]) if (best === undefined || version > best.version) { best = { path: join(dir, fileName), version, compressed: match[2] === '.zstd' } } } return best } /** * Replace the header record's `cwd` in one stored log. The rest of the * artifact (all committed event frames, plus any torn tail the backend will * recover) is copied through byte for byte. */ async function rewriteHeaderCwd(logPath, compressed, nextCwd) { const before = await readFile(logPath) const inspected = inspectHeader(before, compressed) if (typeof inspected.header !== 'object' || inspected.header === null || inspected.header.type !== 'session') { throw new Error('corrupt session log: first line is not a session header') } const header = { ...inspected.header, cwd: nextCwd } const text = `${JSON.stringify(header)}\n` let next if (compressed) { const frame = zstdCompressSync(Buffer.from(text, 'utf8'), CHECKSUM_OPTIONS) const decoded = zstdDecompressSync(frame).toString('utf8') if (decoded !== text) throw new Error('could not verify the rewritten header frame') next = Buffer.concat([frame, before.subarray(inspected.headerEnd)]) } else { next = Buffer.concat([Buffer.from(text, 'utf8'), before.subarray(inspected.headerEnd)]) } const temporary = `${logPath}.session-move.tmp` const handle = await open(temporary, 'w') try { await handle.writeFile(next) await handle.sync() } finally { await handle.close() } await rename(temporary, logPath) // Read the artifact back: the next open by the backend validates this exact // identity, so a silent failure here must never be reported as success. const after = inspectHeader(await readFile(logPath), compressed) if (after.header.cwd !== nextCwd) throw new Error('rewritten session header did not retain its new cwd') return { header, bytes: next.length } } // ------------------------------------------------------------- locating a session /** The path the backend derives for a session's directory from its header. */ function expectedSessionDir(root, header) { return join(root, projectKey(header.cwd), encodeSegment(header.id)) } /** Find one session's directory, preferring the header-derived location. */ async function locateSessionDir(root, header) { const expected = expectedSessionDir(root, header) if (isDirectory(expected)) return { dir: expected } let entries = [] try { entries = await readdir(root, { withFileTypes: true }) } catch { return { error: `the session root ${root} is not readable` } } const matches = [] const encoded = encodeSegment(header.id) for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue const candidate = join(root, entry.name, encoded) if (isDirectory(candidate)) matches.push(candidate) } if (matches.length === 1) return { dir: matches[0] } if (matches.length > 1) { return { error: `session '${header.id}' has more than one stored directory: ${matches.join(', ')}` } } return { error: `no stored log found for session '${header.id}' (expected ${expected})` } } // ------------------------------------------------------- workspace accounting /** The registry's internal header/path indexes, or undefined when they changed shape. */ function registryIndexes(registry) { const headers = registry.headers const paths = registry.sessionPaths if (!(headers instanceof Map) || !(paths instanceof Map)) return undefined return { headers, paths, missing: registry.missingSessionPaths instanceof Set ? registry.missingSessionPaths : undefined, invalid: registry.invalidSessionPaths instanceof Map ? registry.invalidSessionPaths : undefined, } } /** Every workspace record that durably accounts this session (raw, unfiltered). */ function rawAccounts(registry, sessionId) { const ids = [] try { const table = registry.table if (table !== undefined && typeof table.entries === 'function') { for (const [workspaceId, record] of table.entries()) { if (Array.isArray(record?.sessionIds) && record.sessionIds.includes(sessionId)) ids.push(workspaceId) } return ids } } catch { // fall through to the public projection } for (const workspace of registry.list()) { if (workspace.sessionIds.includes(sessionId)) ids.push(workspace.id) } return ids } /** Resolve a Workspace by id, exact title, or canonical directory path. */ async function resolveWorkspace(registry, reference) { if (typeof reference !== 'string' || reference.length === 0) return { error: 'a target workspace is required' } const direct = registry.get(reference) if (direct !== undefined) return { workspace: direct } const byTitle = registry.list().filter((workspace) => workspace.title === reference) if (byTitle.length === 1) return { workspace: byTitle[0] } if (byTitle.length > 1) { return { error: `workspace title '${reference}' is ambiguous; use its id or directory path` } } if (reference.startsWith('/')) { try { const byPath = await registry.resolveByPath(reference) if (byPath !== undefined) return { workspace: byPath } } catch { return { error: `no registered workspace directory at '${reference}'` } } } return { error: `no registered workspace matches '${reference}'` } } // ---------------------------------------------------- working-directory event /** * Append a `working-directory/change` event when the conversation's effective * working directory (header cwd, then each recorded change) does not already * equal the new Workspace path. Best effort: a failure only warns. */ async function alignWorkingDirectory(ctx, sessionId, sizeBytes, nextCwd, formatStatus) { if (formatStatus !== undefined && formatStatus !== 'current') { return { appended: false, reason: 'the stored log is on an older format generation; it keeps its recorded directory and is migrated when next opened', } } if (typeof sizeBytes === 'number' && sizeBytes > PROJECTION_SCAN_LIMIT_BYTES) { return { appended: false, reason: 'session log is too large to scan for an effective working directory' } } const persistence = ctx.sessionPersistence let events = [] const reader = await persistence.open(sessionId, 'read') try { const result = await reader.read() events = result.events } finally { await reader.close() } let effective = nextCwd for (const event of events) { if (event.type === 'working-directory/change' && typeof event.data?.cwd === 'string') effective = event.data.cwd } if (events.length === 0 || effective === nextCwd) return { appended: false } const writer = await persistence.open(sessionId, 'write') try { await writer.append([ { type: 'working-directory/change', seq: events.length, time: Date.now(), data: { cwd: nextCwd }, }, ]) await writer.flush() } finally { await writer.close() } return { appended: true } } // ------------------------------------------------------------ staged-move recovery /** * Complete an interrupted move: a directory parked in staging already carries * the header that names its destination, so the destination can be derived and * the rename finished. Runs once per plugin activation. */ async function recoverStagedMoves(ctx) { const root = stagingRoot() let entries = [] try { entries = await readdir(root, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (!entry.isDirectory()) continue const staged = join(root, entry.name) try { const log = findCurrentLogFile(staged) if (log === undefined) continue const buffer = await readFile(log.path) const { header } = inspectHeader(buffer, log.compressed) if (typeof header.cwd !== 'string' || header.cwd.length === 0) continue const destination = join(sessionsRoot(), projectKey(header.cwd), encodeSegment(header.id)) if (isDirectory(destination)) { ctx.logger.warn(`session-move: staged directory ${staged} collides with ${destination}; left in place`) continue } await mkdir(dirname(destination), { recursive: true }) await rename(staged, destination) ctx.logger.info(`session-move: completed an interrupted move of '${header.id}' to ${destination}`) } catch (error) { ctx.logger.warn(`session-move: could not recover staged directory ${staged}: ${String(error)}`) } } } // ------------------------------------------------------------------- the move function ok(extra) { return { ok: true, ...extra } } function fail(code, message, extra) { return { ok: false, code, message, ...extra } } /** * The persistence write handle of a live session. The JSONL backend keeps one * writer per id in its tracker; relocating the log under a live session is only * safe when that handle's header can be repointed at the same time, because * every append derives its path from the header (`logPath(root, header.cwd, id)`). */ function findWriteHandle(persistence, sessionId) { try { const writers = persistence.tracker?.writers if (writers === undefined || typeof writers.get !== 'function') return undefined const handle = writers.get(sessionId) if (handle === undefined || handle === null) return undefined return handle.access === 'write' ? handle : undefined } catch { return undefined } } /** Undo the in-memory repointing of a live session after a failed move. */ function restoreLiveHeaders(patch) { if (patch === undefined) return try { if (patch.session !== undefined && patch.previousSessionHeader !== undefined) { patch.session.header = patch.previousSessionHeader } } catch { // best effort } try { if (patch.writer !== undefined) patch.writer.header = patch.previousWriterHeader } catch { // best effort } } function liveSessionOf(ctx, sessionId) { try { const sessions = ctx.get('sessions') if (sessions === undefined || typeof sessions.get !== 'function') return undefined return sessions.get(sessionId) } catch { return undefined } } function liveAgentOf(ctx, sessionId) { try { const agents = ctx.get('agents') if (agents === undefined || typeof agents.get !== 'function') return undefined return agents.get(sessionId) } catch { return undefined } } /** * Move one conversation to another Workspace. * @returns a plain result object; failures carry `ok: false` and a stable `code`. */ async function moveSession(ctx, request) { const registry = ctx.workspaceRegistry const persistence = ctx.sessionPersistence const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : '' if (sessionId.length === 0) return fail('request/missing-session', 'sessionId is required') const resolved = await resolveWorkspace(registry, request.workspaceId ?? request.workspaceRef) if (resolved.error !== undefined) return fail('workspace/not-found', resolved.error) const target = resolved.workspace const snapshot = await persistence.stat(sessionId) if (snapshot === undefined) return fail('session/not-found', `no stored conversation '${sessionId}'`) const header = snapshot.header if (typeof header.cwd !== 'string' || header.cwd.length === 0) { return fail('session/no-cwd', `session '${sessionId}' has no recorded directory, so it cannot belong to a workspace`) } // A conversation that is open in this process can be moved too: its live // header and its persistence write handle are repointed together, so every // later append lands in the new project directory. Work in progress is the // one thing that must not race the relocation. const liveSession = liveSessionOf(ctx, header.id) ?? (header.id === sessionId ? undefined : liveSessionOf(ctx, sessionId)) const liveAgent = liveAgentOf(ctx, header.id) ?? (header.id === sessionId ? undefined : liveAgentOf(ctx, sessionId)) const live = liveSession !== undefined || liveAgent !== undefined const liveWriter = live ? findWriteHandle(persistence, header.id) : undefined if (liveAgent !== undefined) { let activity try { activity = await ctx.waterfall('workspace/session-activity', { sessionId: header.id }, () => Promise.resolve([])) } catch (error) { return fail('session/activity-unknown', `could not confirm the conversation is idle: ${String(error)}`, { live: true, writerFound: liveWriter !== undefined, }) } if (Array.isArray(activity) && activity.length > 0) { const kinds = activity.map((entry) => entry?.kind).filter((kind) => typeof kind === 'string') return fail( 'session/active', `the conversation is still working${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}; wait for the current turn to finish`, { activity: kinds, live: true, writerFound: liveWriter !== undefined }, ) } } if (await samePath(header.cwd, target.path)) { return ok({ moved: false, code: 'session/same-workspace', message: 'the conversation already belongs to this workspace', sessionId, workspaceId: target.id, workspacePath: target.path, }) } if ((await target.status()) !== 'ok') { return fail('workspace/missing-dir', `the workspace directory ${target.path} no longer exists`) } const root = sessionsRoot() const located = await locateSessionDir(root, header) if (located.error !== undefined) return fail('session/log-not-found', located.error) const sourceDir = located.dir const finalDir = join(root, projectKey(target.path), encodeSegment(header.id)) if (isDirectory(finalDir)) { return fail('session/target-exists', `the target session directory already exists: ${finalDir}`) } const accounts = rawAccounts(registry, sessionId) // At most one Workspace can own the session's current path, but a record may // still account a session that the membership getter filters out (for example // after the directory went missing). Detach from that record too, so the // account never ends up in two Workspaces. let previousPath try { previousPath = await realpath(header.cwd) } catch { previousPath = header.cwd } const pathOwners = registry .list() .filter((workspace) => workspace.path === previousPath) .map((workspace) => workspace.id) const sourceWorkspaceIds = [...new Set([...accounts, ...pathOwners])].filter((id) => id !== target.id) if (request.dryRun === true) { return ok({ moved: false, dryRun: true, sessionId, live, writerFound: liveWriter !== undefined, fromWorkspaceIds: sourceWorkspaceIds, fromPath: header.cwd, toWorkspaceId: target.id, toPath: target.path, sourceDir, finalDir, }) } // For a live conversation, flush its buffered events and repoint the live // header and the write handle before touching the disk, so appends cannot // recreate the old project directory. let livePatch if (live) { if (liveSession !== undefined) { try { const sessions = ctx.get('sessions') if (sessions !== undefined && typeof sessions.flush === 'function') await sessions.flush(liveSession) } catch (error) { return fail('session/flush-failed', `could not flush the open conversation before moving it: ${String(error)}`) } } const writer = liveWriter if (writer === undefined) { return fail('session/writer-unavailable', 'the open conversation has no writable log handle, so its log cannot be relocated safely') } livePatch = { session: liveSession, writer, previousSessionHeader: liveSession !== undefined ? liveSession.header : undefined, previousWriterHeader: writer.header, } const nextHeader = headerWithCwd(header, target.path) try { if (liveSession !== undefined) liveSession.header = nextHeader writer.header = nextHeader } catch (error) { restoreLiveHeaders(livePatch) return fail('move/live-patch-failed', `could not repoint the open conversation: ${String(error)}`) } } // ---- 1. relocate the stored log: stage, rewrite the header, publish. const staging = stagingRoot() await mkdir(staging, { recursive: true }) const staged = join(staging, `${encodeSegment(header.id)}-${Date.now()}`) try { await rename(sourceDir, staged) } catch (error) { return fail('move/file-failed', `could not detach the stored log: ${String(error)}`) } let logFile try { logFile = findCurrentLogFile(staged) if (logFile === undefined) throw new Error('the session directory holds no current log generation') await rewriteHeaderCwd(logFile.path, logFile.compressed, target.path) await mkdir(dirname(finalDir), { recursive: true }) await rename(staged, finalDir) } catch (error) { try { if (logFile !== undefined) await rewriteHeaderCwd(logFile.path, logFile.compressed, header.cwd) await rename(staged, sourceDir) } catch (rollbackError) { ctx.logger.warn(`session-move: rollback after a failed relocation also failed: ${String(rollbackError)}`) } restoreLiveHeaders(livePatch) return fail('move/file-failed', `could not relocate the stored log: ${String(error)}`) } // ---- 2. workspace accounting: repoint the header index, then attach/detach. const indexes = registryIndexes(registry) if (indexes === undefined) { await rollbackLog(root, finalDir, staged, header, logFile) restoreLiveHeaders(livePatch) return fail('runtime/incompatible', 'the workspace registry no longer exposes the header index this plugin needs') } const previous = { path: indexes.paths.get(header.id), header: indexes.headers.get(header.id), missing: indexes.missing?.has(header.id) === true, invalid: indexes.invalid?.get(header.id), } indexes.headers.set(header.id, headerWithCwd(header, target.path)) indexes.paths.set(header.id, target.path) indexes.missing?.delete(header.id) indexes.invalid?.delete(header.id) const targetWorkspace = registry.get(target.id) const attached = [] try { await targetWorkspace.attachSession(header.id) attached.push(target.id) for (const workspaceId of sourceWorkspaceIds) { const workspace = registry.get(workspaceId) if (workspace === undefined) continue await workspace.detachSession(header.id) } } catch (error) { // Undo the accounting we managed to apply, then put the log back. for (const workspaceId of attached) { try { await registry.get(workspaceId)?.detachSession(header.id) } catch { // best effort } } restoreIndexEntry(indexes, header.id, previous) await rollbackLog(root, finalDir, staged, header, logFile) restoreLiveHeaders(livePatch) return fail('move/workspace-failed', `could not update workspace membership: ${String(error)}`) } // ---- 3. best effort: keep the conversation's effective directory in sync. let warning if (live) { if (liveAgent !== undefined) { try { const workingDirectory = ctx.get('workingDirectory') if (workingDirectory === undefined || typeof workingDirectory.set !== 'function') { warning = 'the conversation moved, but the working-directory service is unavailable; reopen it to pick up the new directory' } else { await workingDirectory.set(liveAgent, target.path) } } catch (error) { warning = `the conversation moved, but its running agent kept its previous working directory: ${String(error)}` } } // Drain anything buffered while the log was in flight, now through the // repointed header (the new path). if (liveSession !== undefined) { try { const sessions = ctx.get('sessions') if (sessions !== undefined && typeof sessions.flush === 'function') await sessions.flush(liveSession) } catch (error) { const next = `the conversation moved, but its buffered events could not be flushed: ${String(error)}` warning = warning === undefined ? next : `${warning}; ${next}` } } if (warning !== undefined) ctx.logger.warn(`session-move: ${warning}`) } else { try { const aligned = await alignWorkingDirectory(ctx, header.id, snapshot.sizeBytes, target.path, snapshot.formatStatus) if (aligned.reason !== undefined) warning = aligned.reason } catch (error) { warning = `the conversation moved, but its working-directory event was not written: ${String(error)}` ctx.logger.warn(`session-move: ${warning}`) } } return ok({ moved: true, sessionId, fromWorkspaceIds: sourceWorkspaceIds, fromPath: header.cwd, toWorkspaceId: target.id, toPath: target.path, ...(warning === undefined ? {} : { warning }), }) } /** Whether the recorded directory already resolves to the workspace directory. */ async function samePath(left, right) { if (left === right) return true try { return (await realpath(left)) === right } catch { return false } } function headerWithCwd(header, cwd) { return { ...header, cwd } } /** Restore one header-index entry exactly as it was before the move. */ function restoreIndexEntry(indexes, sessionId, previous) { if (previous.header === undefined) indexes.headers.delete(sessionId) else indexes.headers.set(sessionId, previous.header) if (previous.path === undefined) indexes.paths.delete(sessionId) else indexes.paths.set(sessionId, previous.path) if (previous.missing === true) indexes.missing?.add(sessionId) if (previous.invalid !== undefined) indexes.invalid?.set(sessionId, previous.invalid) else indexes.invalid?.delete(sessionId) } /** Put a relocated log back where it was, restoring the original header cwd. */ async function rollbackLog(root, finalDir, staged, header, logFile) { try { if (isDirectory(finalDir)) await rename(finalDir, staged) if (logFile !== undefined && isDirectory(staged)) { const current = findCurrentLogFile(staged) if (current !== undefined) await rewriteHeaderCwd(current.path, current.compressed, header.cwd) const original = expectedSessionDir(root, header) await mkdir(dirname(original), { recursive: true }) await rename(staged, original) } } catch { // A failed rollback is reported by the caller's failure result; the staged // copy is recovered on the next plugin activation. } } // -------------------------------------------------------------- HTTP endpoint function readBody(req) { return new Promise((resolve, reject) => { const chunks = [] req.on('data', (chunk) => chunks.push(chunk)) req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) req.on('error', reject) }) } function statusFor(result) { if (result.ok === true) return 200 switch (result.code) { case 'request/missing-session': return 400 case 'session/not-found': case 'session/log-not-found': case 'workspace/not-found': return 404 case 'session/active': case 'session/writer-unavailable': case 'session/same-workspace': case 'session/target-exists': case 'workspace/missing-dir': return 409 default: return 500 } } async function handle(ctx, req, res) { const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) res.end(JSON.stringify(body)) } if (req.method !== 'POST') return send(405, fail('request/method', 'POST is required')) let payload try { payload = JSON.parse(await readBody(req)) } catch { return send(400, fail('request/json', 'the request body is not valid JSON')) } const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : '' const workspaceId = typeof payload?.workspaceId === 'string' ? payload.workspaceId.trim() : '' if (sessionId.length === 0 || workspaceId.length === 0) { return send(400, fail('request/missing-session', 'sessionId and workspaceId are required')) } try { const result = await moveSession(ctx, { sessionId, workspaceId, dryRun: payload?.dryRun === true, }) return send(statusFor(result), result) } catch (error) { ctx.logger.warn(`session-move: unhandled failure: ${String(error)}`) return send(500, fail('move/failed', error instanceof Error ? error.message : String(error))) } } // ------------------------------------------------------------------- agent tool function toolDefinition(ctx) { return { name: TOOL_NAME, description: 'Move one existing conversation (Session) from its current Workspace to another registered Workspace, ' + 'so it is grouped under that Workspace and continues in its directory. A conversation that is open in ' + 'the current window moves too, but a turn that is still running refuses. Identify the target Workspace ' + 'by id, exact title, or canonical directory path.', parameters: { type: 'object', additionalProperties: false, required: ['sessionId', 'workspace'], properties: { sessionId: { type: 'string', description: 'The conversation (Session) id to move.', }, workspace: { type: 'string', description: "Target Workspace id, exact title, or canonical directory path.", }, }, }, output: { schema: { type: 'object', required: ['ok'], properties: { ok: { type: 'boolean', description: 'Whether the move succeeded.' }, moved: { type: 'boolean', description: 'Whether a change was applied (false for a no-op).' }, code: { type: 'string', description: 'Stable failure or no-op code.' }, message: { type: 'string', description: 'Human-readable detail.' }, sessionId: { type: 'string', description: 'The conversation id.' }, workspaceId: { type: 'string', description: 'The target Workspace id.' }, workspacePath: { type: 'string', description: 'The target Workspace directory.' }, fromPath: { type: 'string', description: 'The previous Workspace directory.' }, warning: { type: 'string', description: 'A non-fatal caveat.' }, }, }, render: (_args, value) => { if (value !== null && typeof value === 'object' && value.ok === true) { if (value.moved === false) { return [{ type: 'text', text: `No move was needed: ${value.message ?? 'the conversation is already in that workspace'}` }] } return [ { type: 'text', text: `Moved conversation ${value.sessionId} from ${value.fromPath ?? 'its previous workspace'} ` + `to ${value.workspacePath ?? value.workspaceId}.` + (typeof value.warning === 'string' ? ` Note: ${value.warning}` : ''), }, ] } const code = value !== null && typeof value === 'object' ? value.code : undefined const message = value !== null && typeof value === 'object' ? value.message : undefined return [{ type: 'text', text: `Move failed${code === undefined ? '' : ` (${code})`}: ${message ?? 'unknown error'}` }] }, }, async execute(args) { const result = await moveSession(ctx, { sessionId: args.sessionId, workspaceRef: args.workspace }) // Keep the tool result exactly on the declared schema. const { ok, moved, code, message, sessionId, workspaceId, workspacePath, fromPath, warning } = result const value = { ok } if (moved !== undefined) value.moved = moved if (code !== undefined) value.code = code if (message !== undefined) value.message = message if (sessionId !== undefined) value.sessionId = sessionId if (workspaceId !== undefined) value.workspaceId = workspaceId if (workspacePath !== undefined) value.workspacePath = workspacePath if (fromPath !== undefined) value.fromPath = fromPath if (warning !== undefined) value.warning = warning return value }, } } // ----------------------------------------------------------------- entry point export function apply(ctx) { const webServer = ctx.get('webServer') if (webServer !== undefined) { ctx.effect( () => webServer.register({ kind: 'exact', path: ROUTE, handler: (req, res) => handle(ctx, req, res), }), 'session-move:route', ) } ctx.inject(['tools'], (scope) => { scope.effect(() => scope.tools.register(toolDefinition(ctx)), 'session-move:tool') }) // Finish any move interrupted by a process exit before serving requests. ctx.effect(() => { let active = true void (async () => { try { if (active) await recoverStagedMoves(ctx) } catch (error) { ctx.logger.warn(`session-move: staged-move recovery failed: ${String(error)}`) } })() return () => { active = false } }, 'session-move:staged-recovery') }