diff --git a/.changeset/session-archive.md b/.changeset/session-archive.md new file mode 100644 index 000000000..248ea98a2 --- /dev/null +++ b/.changeset/session-archive.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add session archive and unarchive commands to hide inactive sessions without deleting them. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..07cb4f66b 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerSessionCommand } from './sub/session'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -80,6 +81,7 @@ export function createProgram( registerAcpCommand(program); registerLoginCommand(program); registerDoctorCommand(program); + registerSessionCommand(program); registerMigrateCommand(program, onMigrate); program .command('upgrade') diff --git a/apps/kimi-code/src/cli/sub/session.ts b/apps/kimi-code/src/cli/sub/session.ts new file mode 100644 index 000000000..1e32b3e3f --- /dev/null +++ b/apps/kimi-code/src/cli/sub/session.ts @@ -0,0 +1,107 @@ +import { + createKimiHarness, + type KimiHarness, + type SessionSummary, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { shutdownTelemetry, track, withTelemetryContext } from '@moonshot-ai/kimi-telemetry'; + +interface SessionDeps { + readonly archiveSession: (id: string) => Promise; + readonly unarchiveSession: (id: string) => Promise; + readonly stdout: { write(chunk: string): boolean }; + readonly stderr: { write(chunk: string): boolean }; + readonly exit: (code: number) => never; +} + +export function registerSessionCommand(parent: Command, deps?: Partial): void { + const sessionCmd = parent.command('session').description('Manage sessions.'); + + sessionCmd + .command('archive ') + .description('Archive a session, hiding it from the default session picker.') + .action(async (id: string) => { + const commandDeps = createDefaultSessionDeps(deps); + try { + const summary = await commandDeps.archiveSession(id.trim()); + commandDeps.stdout.write(`Archived session: ${summary.id}\n`); + } catch (error) { + commandDeps.stderr.write(`${errorMessage(error)}\n`); + commandDeps.exit(1); + } + }); + + sessionCmd + .command('unarchive ') + .description('Restore an archived session to the default session picker.') + .action(async (id: string) => { + const commandDeps = createDefaultSessionDeps(deps); + try { + const summary = await commandDeps.unarchiveSession(id.trim()); + commandDeps.stdout.write(`Unarchived session: ${summary.id}\n`); + } catch (error) { + commandDeps.stderr.write(`${errorMessage(error)}\n`); + commandDeps.exit(1); + } + }); +} + +function createDefaultSessionDeps(overrides: Partial = {}): SessionDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + const bootstrap = createCliTelemetryBootstrap(); + const getHarness = (): KimiHarness => { + harness ??= createKimiHarness({ + homeDir: bootstrap.homeDir, + identity, + telemetry: { + track, + withContext: withTelemetryContext, + setContext: () => {}, + }, + }); + return harness; + }; + + const withTelemetry = async (fn: (h: KimiHarness) => Promise): Promise => { + const h = getHarness(); + await h.ensureConfigFile(); + const config = await h.getConfig(); + initializeCliTelemetry({ + harness: h, + bootstrap, + config, + version: identity.version, + uiMode: CLI_UI_MODE, + }); + try { + return await fn(h); + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } + }; + + return { + archiveSession: + overrides.archiveSession ?? + (async (id: string) => { + return withTelemetry((h) => h.archiveSession(id)); + }), + unarchiveSession: + overrides.unarchiveSession ?? + (async (id: string) => { + return withTelemetry((h) => h.unarchiveSession(id)); + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 397404e0f..2e5bb9336 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -49,6 +49,7 @@ import { handlePluginsCommand } from './plugins'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import { handleSwarmCommand } from './swarm'; import { + handleArchiveCommand, handleExportDebugZipCommand, handleExportMdCommand, handleForkCommand, @@ -90,6 +91,7 @@ export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; export { + handleArchiveCommand, handleExportDebugZipCommand, handleExportMdCommand, handleForkCommand, @@ -299,6 +301,9 @@ async function handleBuiltInSlashCommand( case 'title': await handleTitleCommand(host, args); return; + case 'archive': + await handleArchiveCommand(host, args); + return; case 'yolo': await handleYoloCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..49eb8d617 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -211,6 +211,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'archive', + aliases: [], + description: 'Archive or unarchive the current session', + priority: 60, + availability: 'always', + }, { name: 'usage', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0..b960ba9c9 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -157,6 +157,42 @@ export async function handleExportDebugZipCommand(host: SlashCommandHost): Promi } } +export async function handleArchiveCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const subcmd = args.trim().toLowerCase(); + const currentlyArchived = session.summary?.archived === true; + const shouldArchive = subcmd !== 'off' && subcmd !== 'unarchive'; + + if (shouldArchive && currentlyArchived) { + host.showStatus('Session is already archived.'); + return; + } + if (!shouldArchive && !currentlyArchived) { + host.showStatus('Session is not archived.'); + return; + } + + try { + if (shouldArchive) { + await session.archive(); + host.track('session_archived', { session_id: session.id }); + host.showNotice('Session archived', 'Hidden from the default session picker.'); + } else { + await session.unarchive(); + host.track('session_unarchived', { session_id: session.id }); + host.showNotice('Session unarchived', 'Visible in the default session picker.'); + } + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to update archive state: ${msg}`); + } +} + export async function handleInitCommand(host: SlashCommandHost): Promise { const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index 8848d8b7c..b87cb26c3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -20,6 +20,7 @@ export interface SessionRow { readonly last_prompt?: string | null; readonly work_dir: string; readonly updated_at: number; + readonly archived?: boolean | undefined; readonly metadata?: Readonly> | undefined; } @@ -225,11 +226,12 @@ export class SessionPickerComponent extends Container implements Focusable { const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; + const archivedBadge = session.archived === true ? 'archived' : ''; const rawTitle = (session.title ?? session.id).trim() || session.id; const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); - // Inline trailing parts after the title: " <time> ← current". - const trailingParts = [time, badge].filter((p) => p.length > 0); + // Inline trailing parts after the title: "<title> <time> ← current archived". + const trailingParts = [time, badge, archivedBadge].filter((p) => p.length > 0); const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : ''; const trailingWidth = visibleWidth(trailingText); const headerPrefixWidth = visibleWidth(pointer) + 1; // pointer + space diff --git a/apps/kimi-code/src/tui/utils/session-picker-rows.ts b/apps/kimi-code/src/tui/utils/session-picker-rows.ts index 55e063246..6b6336484 100644 --- a/apps/kimi-code/src/tui/utils/session-picker-rows.ts +++ b/apps/kimi-code/src/tui/utils/session-picker-rows.ts @@ -8,6 +8,7 @@ export function sessionRowsForPicker( currentSessionHasContent: boolean, ): SessionRow[] { return sessions + .filter((session) => !session.archived) .filter((session) => currentSessionHasContent || session.id !== currentSessionId) .map((session) => ({ id: session.id, @@ -15,6 +16,7 @@ export function sessionRowsForPicker( last_prompt: session.lastPrompt ?? null, work_dir: session.workDir, updated_at: session.updatedAt ?? session.createdAt ?? 0, + archived: session.archived === true, metadata: session.metadata, })); } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index e14629e01..e421907da 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -347,6 +347,7 @@ describe('CLI options parsing', () => { 'acp', 'login', 'doctor', + 'session', 'migrate', 'upgrade', ]); diff --git a/apps/kimi-code/test/cli/session.test.ts b/apps/kimi-code/test/cli/session.test.ts new file mode 100644 index 000000000..2fcf1f400 --- /dev/null +++ b/apps/kimi-code/test/cli/session.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { registerSessionCommand } from '#/cli/sub/session'; +import type { SessionSummary } from '@moonshot-ai/kimi-code-sdk'; + +function createProgramWithDeps(overrides: { + readonly archiveSession?: () => Promise<SessionSummary>; + readonly unarchiveSession?: () => Promise<SessionSummary>; +} = {}) { + const { Command } = require('commander'); + const program = new Command('kimi'); + const stdout: string[] = []; + const stderr: string[] = []; + const exits: number[] = []; + registerSessionCommand(program, { + archiveSession: overrides.archiveSession ?? vi.fn().mockResolvedValue({ id: 'ses-1' } as SessionSummary), + unarchiveSession: overrides.unarchiveSession ?? vi.fn().mockResolvedValue({ id: 'ses-1' } as SessionSummary), + stdout: { write: (chunk: string) => { stdout.push(chunk); return true; } }, + stderr: { write: (chunk: string) => { stderr.push(chunk); return true; } }, + exit: (code: number) => { exits.push(code); throw new Error(`exit ${code}`); }, + }); + return { program, stdout, stderr, exits }; +} + +describe('session CLI subcommand', () => { + it('archives a session by id', async () => { + const archiveSession = vi.fn().mockResolvedValue({ id: 'ses-abc' } as SessionSummary); + const { program, stdout } = createProgramWithDeps({ archiveSession }); + + await program.parseAsync(['node', 'kimi', 'session', 'archive', 'ses-abc']); + + expect(archiveSession).toHaveBeenCalledWith('ses-abc'); + expect(stdout.join('')).toContain('Archived session: ses-abc'); + }); + + it('unarchives a session by id', async () => { + const unarchiveSession = vi.fn().mockResolvedValue({ id: 'ses-abc' } as SessionSummary); + const { program, stdout } = createProgramWithDeps({ unarchiveSession }); + + await program.parseAsync(['node', 'kimi', 'session', 'unarchive', 'ses-abc']); + + expect(unarchiveSession).toHaveBeenCalledWith('ses-abc'); + expect(stdout.join('')).toContain('Unarchived session: ses-abc'); + }); + + it('prints an error and exits when archiving fails', async () => { + const archiveSession = vi.fn().mockRejectedValue(new Error('session not found')); + const { program, stderr, exits } = createProgramWithDeps({ archiveSession }); + + await expect(program.parseAsync(['node', 'kimi', 'session', 'archive', 'missing'])).rejects.toThrow('exit 1'); + expect(archiveSession).toHaveBeenCalledWith('missing'); + expect(stderr.join('')).toContain('session not found'); + expect(exits).toEqual([1]); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/session.test.ts b/apps/kimi-code/test/tui/commands/session.test.ts new file mode 100644 index 000000000..9727ccecc --- /dev/null +++ b/apps/kimi-code/test/tui/commands/session.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleArchiveCommand } from '#/tui/commands/session'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +function makeHost(overrides: { + readonly hasSession?: boolean; + readonly archived?: boolean; +} = {}) { + const session = { + id: 'ses-1', + summary: { archived: overrides.archived === true }, + archive: vi.fn(async () => {}), + unarchive: vi.fn(async () => {}), + }; + const host = { + session: overrides.hasSession === false ? undefined : session, + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +describe('handleArchiveCommand', () => { + it('shows an error when there is no active session', async () => { + const { host } = makeHost({ hasSession: false }); + await handleArchiveCommand(host, ''); + expect(host.showError).toHaveBeenCalled(); + expect(host.session).toBeUndefined(); + }); + + it('archives the current session when no subcommand is given', async () => { + const { host, session } = makeHost({ archived: false }); + await handleArchiveCommand(host, ''); + expect(session.archive).toHaveBeenCalledOnce(); + expect(session.unarchive).not.toHaveBeenCalled(); + expect(host.showNotice).toHaveBeenCalledWith( + 'Session archived', + 'Hidden from the default session picker.', + ); + expect(host.track).toHaveBeenCalledWith('session_archived', { session_id: 'ses-1' }); + }); + + it('unarchives the current session when given "off"', async () => { + const { host, session } = makeHost({ archived: true }); + await handleArchiveCommand(host, 'off'); + expect(session.unarchive).toHaveBeenCalledOnce(); + expect(session.archive).not.toHaveBeenCalled(); + expect(host.showNotice).toHaveBeenCalledWith( + 'Session unarchived', + 'Visible in the default session picker.', + ); + expect(host.track).toHaveBeenCalledWith('session_unarchived', { session_id: 'ses-1' }); + }); + + it('unarchives the current session when given "unarchive"', async () => { + const { host, session } = makeHost({ archived: true }); + await handleArchiveCommand(host, 'unarchive'); + expect(session.unarchive).toHaveBeenCalledOnce(); + expect(session.archive).not.toHaveBeenCalled(); + }); + + it('reports when the session is already archived', async () => { + const { host, session } = makeHost({ archived: true }); + await handleArchiveCommand(host, ''); + expect(session.archive).not.toHaveBeenCalled(); + expect(session.unarchive).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Session is already archived.'); + }); + + it('reports when the session is not archived', async () => { + const { host, session } = makeHost({ archived: false }); + await handleArchiveCommand(host, 'off'); + expect(session.archive).not.toHaveBeenCalled(); + expect(session.unarchive).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Session is not archived.'); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts index 30d56969c..7932c0ddd 100644 --- a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts +++ b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts @@ -7,11 +7,13 @@ function summary(input: { readonly id: string; readonly title?: string; readonly lastPrompt?: string; + readonly archived?: boolean; }): SessionSummary { return { id: input.id, title: input.title, lastPrompt: input.lastPrompt, + archived: input.archived, workDir: '/tmp/project', sessionDir: `/tmp/home/sessions/${input.id}`, createdAt: 1, @@ -61,4 +63,18 @@ describe('sessionRowsForPicker', () => { expect(rows.map((row) => row.id)).toEqual(['ses_previous_empty']); }); + + it('filters archived sessions from the default picker', () => { + const rows = sessionRowsForPicker( + [ + summary({ id: 'ses_active', title: 'Active Session' }), + summary({ id: 'ses_archived', title: 'Archived Session', archived: true }), + ], + 'ses_active', + true, + ); + + expect(rows.map((row) => row.id)).toEqual(['ses_active']); + expect(rows[0]?.archived).toBe(false); + }); }); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index b080802ee..068e712e8 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -124,6 +124,15 @@ export interface ExportSessionResult { export interface ListSessionsPayload { readonly workDir?: string; readonly sessionId?: string; + readonly includeArchived?: boolean | undefined; +} + +export interface ArchiveSessionPayload { + readonly sessionId: string; +} + +export interface UnarchiveSessionPayload { + readonly sessionId: string; } export interface CoreInfo { @@ -372,6 +381,8 @@ export interface CoreAPI extends SessionAPIWithId { reloadSession: (payload: ReloadSessionPayload) => ResumeSessionResult; forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; + archiveSession: (payload: ArchiveSessionPayload) => SessionSummary; + unarchiveSession: (payload: UnarchiveSessionPayload) => SessionSummary; exportSession: (payload: ExportSessionPayload) => ExportSessionResult; listPlugins: (payload: EmptyPayload) => readonly PluginSummary[]; installPlugin: (payload: InstallPluginPayload) => PluginSummary; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 204715da6..ba8a22d52 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -64,6 +64,8 @@ import type { GetPluginInfoPayload, InstallPluginPayload, ListSessionsPayload, + ArchiveSessionPayload, + UnarchiveSessionPayload, McpServerInfo, McpStartupMetrics, PluginInfo, @@ -419,6 +421,14 @@ export class KimiCore implements PromisableMethods<CoreAPI> { return this.sessionStore.list(input); } + async archiveSession({ sessionId }: ArchiveSessionPayload): Promise<SessionSummary> { + return this.sessionStore.archive(sessionId); + } + + async unarchiveSession({ sessionId }: UnarchiveSessionPayload): Promise<SessionSummary> { + return this.sessionStore.unarchive(sessionId); + } + async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise<void> { const session = this.sessions.get(sessionId); if (session !== undefined) { diff --git a/packages/agent-core/src/session/store/session-index.ts b/packages/agent-core/src/session/store/session-index.ts index 0753f4ec6..62dd2539f 100644 --- a/packages/agent-core/src/session/store/session-index.ts +++ b/packages/agent-core/src/session/store/session-index.ts @@ -5,6 +5,7 @@ export interface SessionIndexEntry { readonly sessionId: string; readonly sessionDir: string; readonly workDir: string; + readonly archived?: boolean | undefined; } export function sessionIndexPath(homeDir: string): string { @@ -67,6 +68,7 @@ function parseIndexLine(line: string): SessionIndexEntry | undefined { sessionId: entry.sessionId, sessionDir: entry.sessionDir, workDir: entry.workDir, + archived: entry.archived === true, }; } catch { return undefined; diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 0daa6063d..a99d6e623 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -15,6 +15,7 @@ const SessionSummaryStateSchema = z.object({ isCustomTitle: z.boolean().optional(), lastPrompt: z.string().optional(), title: z.string().optional(), + archived: z.boolean().optional(), custom: z.record(z.string(), z.unknown()).optional(), }); @@ -140,27 +141,67 @@ export class SessionStore { await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); } + async archive(id: string): Promise<SessionSummary> { + return this.setArchived(id, true); + } + + async unarchive(id: string): Promise<SessionSummary> { + return this.setArchived(id, false); + } + + private async setArchived(id: string, archived: boolean): Promise<SessionSummary> { + const entry = await this.findExistingSessionEntry(id); + const statePath = join(entry.sessionDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch (error) { + throw new KimiError(ErrorCodes.SESSION_STATE_NOT_FOUND, `Session "${id}" state.json was not found`, { + cause: error, + }); + } + if (!isRecord(parsed)) { + throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, `Session "${id}" state.json is invalid`); + } + const next: Record<string, unknown> = { + ...parsed, + archived, + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + await appendSessionIndexEntry(this.homeDir, { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + archived, + }); + return this.summaryFromDir(entry.sessionId, entry.sessionDir, entry.workDir); + } + async list(options: ListSessionsPayload = {}): Promise<readonly SessionSummary[]> { const workDir = options.workDir === undefined ? undefined : normalizeRequiredWorkDir(options.workDir); const sessionId = normalizeOptionalSessionId(options.sessionId); + const includeArchived = options.includeArchived === true; if (workDir !== undefined) { if (sessionId !== undefined) { - const local = await this.summaryFromWorkDirSession(sessionId, workDir); + const local = await this.summaryFromWorkDirSession(sessionId, workDir, includeArchived); if (local !== undefined) return [local]; return this.listSessionId(sessionId); } - return this.listWorkDir(workDir); + return this.listWorkDir(workDir, includeArchived); } if (sessionId !== undefined) { return this.listSessionId(sessionId); } - return this.listAll(); + return this.listAll(includeArchived); } - private async listWorkDir(workDir: string): Promise<readonly SessionSummary[]> { + private async listWorkDir( + workDir: string, + includeArchived: boolean, + ): Promise<readonly SessionSummary[]> { const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir)); let entries; try { @@ -175,7 +216,9 @@ export class SessionStore { const id = entry.name; if (!isSafeSessionId(id)) continue; const dir = join(bucketDir, id); - sessions.push(await this.summaryFromDir(id, dir, workDir)); + const summary = await this.summaryFromDir(id, dir, workDir); + if (summary.archived && !includeArchived) continue; + sessions.push(summary); } sessions.sort(compareSessionSummary); return sessions; @@ -192,11 +235,14 @@ export class SessionStore { } } - private async listAll(): Promise<readonly SessionSummary[]> { + private async listAll( + includeArchived: boolean, + ): Promise<readonly SessionSummary[]> { const index = await readSessionIndex(this.homeDir, this.sessionsDir); const sessions: SessionSummary[] = []; for (const entry of index.values()) { if (!(await isDirectory(entry.sessionDir))) continue; + if (entry.archived && !includeArchived) continue; sessions.push(await this.summaryFromDir(entry.sessionId, entry.sessionDir, entry.workDir)); } sessions.sort(compareSessionSummary); @@ -206,11 +252,14 @@ export class SessionStore { private async summaryFromWorkDirSession( sessionId: string, workDir: string, + includeArchived: boolean, ): Promise<SessionSummary | undefined> { if (!isSafeSessionId(sessionId)) return undefined; const sessionDir = this.sessionDirFor({ id: sessionId, workDir }); if (!(await isDirectory(sessionDir))) return undefined; - return this.summaryFromDir(sessionId, sessionDir, workDir); + const summary = await this.summaryFromDir(sessionId, sessionDir, workDir); + if (summary.archived && !includeArchived) return undefined; + return summary; } async assertDirectory(id: string): Promise<string> { @@ -297,6 +346,7 @@ export class SessionStore { ), title: titleFromState(state), lastPrompt: state?.lastPrompt, + archived: state?.archived === true, metadata: metadataFromState(state), }; } diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 1dbd1bdf8..b42dc671e 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -199,6 +199,26 @@ export class KimiHarness { this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title }); } + async archiveSession(id: string): Promise<SessionSummary> { + const normalized = normalizeSessionId(id); + const summary = await this.rpc.archiveSession({ sessionId: normalized }); + const active = this.activeSessions.get(normalized); + if (active !== undefined) { + active.summary = summary; + } + return summary; + } + + async unarchiveSession(id: string): Promise<SessionSummary> { + const normalized = normalizeSessionId(id); + const summary = await this.rpc.unarchiveSession({ sessionId: normalized }); + const active = this.activeSessions.get(normalized); + if (active !== undefined) { + active.summary = summary; + } + return summary; + } + async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> { const result = await this.rpc.exportSession({ ...input, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 10ab9cec5..4a4bb5c9f 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -181,6 +181,16 @@ export abstract class SDKRpcClientBase { }); } + async archiveSession(input: SessionIdRpcInput): Promise<SessionSummary> { + const rpc = await this.getRpc(); + return rpc.archiveSession({ sessionId: input.sessionId }); + } + + async unarchiveSession(input: SessionIdRpcInput): Promise<SessionSummary> { + const rpc = await this.getRpc(); + return rpc.unarchiveSession({ sessionId: input.sessionId }); + } + async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> { const rpc = await this.getRpc(); return rpc.exportSession({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 5e1cffcf5..b77326e70 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -163,6 +163,18 @@ export class Session { await this.rpc.setPermission({ sessionId: this.id, mode }); } + async archive(): Promise<void> { + this.ensureOpen(); + const summary = await this.rpc.archiveSession({ sessionId: this.id }); + this.summary = summary; + } + + async unarchive(): Promise<void> { + this.ensureOpen(); + const summary = await this.rpc.unarchiveSession({ sessionId: this.id }); + this.summary = summary; + } + async setPlanMode(enabled: boolean): Promise<void> { this.ensureOpen(); if (typeof enabled !== 'boolean') {