diff --git a/.changeset/session-meta-updated-at.md b/.changeset/session-meta-updated-at.md new file mode 100644 index 0000000000..2b5303cdfb --- /dev/null +++ b/.changeset/session-meta-updated-at.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Keep session updatedAt stable across metadata management writes: rename and archive/restore no longer bump it, fork inherits the source session's recency, and agent registration is non-touching; add SessionMeta.archivedAt (set on archive, cleared on restore) and surface it as archived_at through the session index and the v1/v2 session routes. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index bdc9795519..9c3c18472b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -454,6 +454,7 @@ export interface SessionStateSnapshot { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 9bd1d4f6fa..cd75ef1767 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -66,6 +66,7 @@ export function buildSessionSummary(fields: { createdAt: number; updatedAt: number; archived: boolean; + archivedAt?: number; custom?: Record; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }): SessionSummary { @@ -78,6 +79,7 @@ export function buildSessionSummary(fields: { createdAt: fields.createdAt, updatedAt: fields.updatedAt, archived: fields.archived, + archivedAt: fields.archivedAt, custom: fields.custom, lastTurnReason: fields.lastTurnReason, }; @@ -108,6 +110,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { a.createdAt === b.createdAt && a.updatedAt === b.updatedAt && a.archived === b.archived && + a.archivedAt === b.archivedAt && a.lastTurnReason === b.lastTurnReason && JSON.stringify(a.custom) === JSON.stringify(b.custom) ); @@ -159,6 +162,7 @@ export async function readSessionSummary( createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), archived: meta['archived'] === true, + archivedAt: meta['archivedAt'] === undefined ? undefined : parseTime(meta['archivedAt']), custom, lastTurnReason: parseTurnOutcome(meta['lastTurnReason']), }); diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 1a6cffccda..ecf83bfe2e 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -28,6 +28,7 @@ export interface SessionWireFields { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly custom?: Record; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 1ec77c346e..b4353decfa 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -95,6 +95,7 @@ export class SessionLegacyService implements ISessionLegacyService { createdAt: meta.createdAt, updatedAt: meta.updatedAt, archived: meta.archived, + archivedAt: meta.archivedAt, custom: meta.custom, }; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 11c75c7ff7..e7af38ce33 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -3,7 +3,8 @@ * * Defines the `SessionMeta` model and the `ISessionMetadata` used by upper * layers to read and update the session's durable metadata (title, timestamps, - * archived flag, fork provenance, the latest main turn's terminal outcome). + * archived flag and the archive moment `archivedAt` — set on archive, cleared + * on restore — fork provenance, the latest main turn's terminal outcome). * Owns the in-memory copy, persists it as a * single atomic document through `storage`, and notifies changes via * `onDidChangeMetadata`. Session-scoped — one instance per session. The initial @@ -33,6 +34,7 @@ export interface SessionMeta { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly>; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 70505926ec..8cd2b79a99 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,10 +10,14 @@ * document always carries the `agents` / `custom` maps — seeded at creation, * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). Re-registering an agent whose metadata is unchanged is - * a no-op (no write, no mirror, no event), so resuming a session — which - * re-registers its agents as they materialize — never bumps `updatedAt` and - * never reorders session listings. Bound at Session scope. + * session listings). `updatedAt` tracks content activity only: management + * writes (rename via `setTitle`, archive/restore via `setArchived`) keep the + * persisted value through `touchUpdatedAt: false`, an explicit + * `patch.updatedAt` always wins (fork restores the source's recency), and + * agent registration is a structural write that never touches it — neither + * when resume materializes a cold session's agents, nor when a runtime + * subagent registers mid-turn (the turn's own submit/end moments carry + * recency). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is recorded into the App-scoped @@ -124,7 +128,8 @@ export class SessionMetadata extends Service implements ISessionMetadata { ): Promise { await this.ready; if (this.disposed) return; - const updatedAt = opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now(); + const updatedAt = + patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now()); this.data = { ...this.data, ...patch, updatedAt }; await this.store.set(this.scope, META_KEY, this.data); if (this.disposed) return; @@ -135,11 +140,14 @@ export class SessionMetadata extends Service implements ISessionMetadata { } async setTitle(title: string): Promise { - await this.update({ title, isCustomTitle: true }); + await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false }); } async setArchived(archived: boolean): Promise { - await this.update({ archived }); + await this.update( + archived ? { archived: true, archivedAt: Date.now() } : { archived: false, archivedAt: undefined }, + { touchUpdatedAt: false }, + ); } async registerAgent(agentId: string, meta: AgentMeta): Promise { @@ -148,7 +156,7 @@ export class SessionMetadata extends Service implements ISessionMetadata { const existing = this.data.agents?.[agentId]; if (existing !== undefined && agentMetaEquals(existing, meta)) return; const agents = { ...this.data.agents, [agentId]: meta }; - await this.applyUpdate({ agents }); + await this.applyUpdate({ agents }, { touchUpdatedAt: false }); }); } @@ -173,6 +181,7 @@ export class SessionMetadata extends Service implements ISessionMetadata { createdAt: this.data.createdAt, updatedAt: this.data.updatedAt, archived: this.data.archived === true, + archivedAt: this.data.archivedAt, custom: this.data.custom, lastTurnReason: this.data.lastTurnReason, }), diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 2eecd65f38..2d4f322283 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -38,6 +38,11 @@ * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent; fork is * confined to this handler (source and target share the workspace bucket). + * Fork restores the source's recency onto the target: the metadata write + * carries an explicit `updatedAt` and runs after agent recreation as the + * fork's final metadata write (agent registration is non-touching), ahead + * of cron duplication, so a mid-fork failure never leaves cloned cron + * records behind. * On * materialize, the agent-profile loaders' `ready` is awaited * before the handle is published — agent-file discovery is local- @@ -115,7 +120,7 @@ import { type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { drainSessionMetadataWrites } from '#/session/sessionMetadata/sessionMetadataService'; +import { drainSessionMetadataWrites, toEpochMs } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { IWireService } from '#/wire/wire'; @@ -525,11 +530,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; + + for (const agentId of agentIds) { + const sourceAgent = sourceAgents[agentId]!; + await target.accessor.get(IAgentLifecycleService).create({ + agentId, + forkedFrom: sourceAgent.forkedFrom, + labels: labelsFromAgentMeta(sourceAgent), + }); + } + await targetMeta.update({ title, isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, forkedFrom: sourceId, archived: false, + updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), lastPrompt: sourceMeta?.lastPrompt, // The fork continues the source's conversation, so it inherits the // last turn's outcome too — otherwise a restart would drop a failure @@ -540,15 +556,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.duplicateCronTasks(sourceId, targetId); - for (const agentId of agentIds) { - const sourceAgent = sourceAgents[agentId]!; - await target.accessor.get(IAgentLifecycleService).create({ - agentId, - forkedFrom: sourceAgent.forkedFrom, - labels: labelsFromAgentMeta(sourceAgent), - }); - } - await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 574eec1520..c4c199f114 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -97,6 +97,57 @@ describe('SessionMetadata', () => { expect(await meta.read()).toMatchObject({ title: 't', archived: true }); }); + it('setTitle keeps updatedAt (rename must not reorder listings)', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + await meta.setTitle('renamed'); + + const next = await meta.read(); + expect(next.title).toBe('renamed'); + expect(next.updatedAt).toBe(before); + }); + + it('setArchived records archivedAt without touching updatedAt; restore clears it', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + + await meta.setArchived(true); + const archived = await meta.read(); + expect(archived.archived).toBe(true); + expect(archived.archivedAt).toBeGreaterThan(before); + expect(archived.updatedAt).toBe(before); + + await meta.setArchived(false); + const restored = await meta.read(); + expect(restored.archived).toBe(false); + expect(restored.archivedAt).toBeUndefined(); + expect(restored.updatedAt).toBe(before); + }); + + it('registerAgent never bumps updatedAt — the agents map is structural', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + // A genuinely NEW agent (resume materializing a cold session's main + // agent, or a runtime subagent) is not content activity. + await meta.registerAgent('main', { homedir: '/tmp/h', type: 'main' }); + + const next = await meta.read(); + expect(next.agents?.['main']?.homedir).toBe('/tmp/h'); + expect(next.updatedAt).toBe(before); + }); + + it('an explicit patch.updatedAt always wins (fork inherits the source recency)', async () => { + const meta = ix.get(ISessionMetadata); + await meta.update({ title: 'fork', updatedAt: 1234 }); + + const next = await meta.read(); + expect(next.title).toBe('fork'); + expect(next.updatedAt).toBe(1234); + }); + it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { @@ -296,7 +347,7 @@ describe('SessionMetadata', () => { expect((await meta.read()).updatedAt).toBe(1700000000000); }); - it('updates when re-registering with changed fields', async () => { + it('updates changed fields on re-registration without bumping updatedAt', async () => { const meta = ix.get(ISessionMetadata); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', @@ -313,7 +364,8 @@ describe('SessionMetadata', () => { const next = await meta.read(); expect(next.agents?.['main']?.labels).toEqual({ swarmItem: 'src/a.ts' }); - expect(next.updatedAt).toBeGreaterThan(before); + // Agent registration is structural, not content activity — listings stay. + expect(next.updatedAt).toBe(before); }); it('records the fresh summary into the session index mirror on update', async () => { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index a848c9521b..d8810934ac 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -924,6 +924,44 @@ describe('SessionLifecycleService', () => { expect(archived).toBe(false); }); + it('restore re-creates the main agent for a cold empty session, then unarchives', async () => { + // The registration itself is non-touching (pinned at the SessionMetadata + // level), so restore only needs the plain unarchive write. + const calls: string[] = []; + const agentHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), + stubPair(ISessionMetadata, { + ...metadataStub(), + setArchived: (value: boolean) => { + calls.push(`setArchived:${value}`); + return Promise.resolve(); + }, + }), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + create: async () => { + calls.push('create'); + return agentHandle; + }, + }), + ]); + + const restored = await svc.restore('s1'); + + expect(restored?.id).toBe('s1'); + expect(calls).toEqual(['create', 'setArchived:false']); + }); + describe('delete', () => { function recordingAppendLogStore(appended: { key: string; record: unknown }[]): IAppendLogStore { return { @@ -1524,6 +1562,96 @@ describe('SessionLifecycleService', () => { expect(forkUpdate?.lastTurnReason).toBe('failed'); }); + it('fork inherits the source session\'s updatedAt (a copy is not fresh activity)', async () => { + const updates: { readonly updatedAt?: unknown }[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + read: () => + Promise.resolve({ updatedAt: 9876, agents: {} } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }; + const svc = await build([stubPair(ISessionMetadata, metaStub)]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + const forkUpdate = updates.find((u) => 'forkedFrom' in u); + expect(forkUpdate?.updatedAt).toBe(9876); + }); + + it('fork normalizes a legacy ISO-string updatedAt from a cold source to epoch ms', async () => { + const updates: { readonly updatedAt?: unknown }[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + // A cold legacy/v1 document read from disk can still carry an ISO + // string — the fork must not persist it as the v2 updatedAt. + read: () => + Promise.resolve({ updatedAt: '2026-08-10T23:00:00.000Z', agents: {} } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }; + const svc = await build([stubPair(ISessionMetadata, metaStub)]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + const forkUpdate = updates.find((u) => 'forkedFrom' in u); + expect(forkUpdate?.updatedAt).toBe(Date.parse('2026-08-10T23:00:00.000Z')); + }); + + it('fork writes the inherited updatedAt AFTER agent registration (registering bumps it)', async () => { + const calls: string[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + read: () => + Promise.resolve({ + updatedAt: 9876, + agents: { main: { type: 'main', homedir: '/tmp/h' } }, + } as never), + update: (patch) => { + calls.push('forkedFrom' in patch ? 'update:fork' : 'update:other'); + return Promise.resolve(); + }, + registerAgent: () => { + calls.push('registerAgent'); + return Promise.resolve(); + }, + }; + const agentHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(ISessionMetadata, metaStub), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + create: async () => { + // Mirror the real doCreate: recreation registers the agent, which + // is an ordinary metadata write (it bumps updatedAt). + await metaStub.registerAgent('main', {}); + return agentHandle; + }, + }), + ]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(calls).toContain('registerAgent'); + expect(calls.indexOf('update:fork')).toBeGreaterThan(calls.lastIndexOf('registerAgent')); + }); + it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = await build([ diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index 9fa454244a..0a43a759dc 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -54,6 +54,9 @@ export const sessionSchema = z.object({ /** Outcome of the main agent's most recent turn. */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + /** When the session was archived (ISO 8601); absent for sessions archived + * before the field existed — clients fall back to `updated_at`. */ + archived_at: isoDateTimeSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(), diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e6cdd0c435..5fc4235e2c 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -1108,6 +1108,7 @@ export interface SessionWireFields { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly custom?: Record; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } @@ -1123,6 +1124,10 @@ export function toWireSession( title: fields.title ?? '', created_at: new Date(fields.createdAt).toISOString(), updated_at: new Date(fields.updatedAt).toISOString(), + // Archive moment; sessions archived before the field existed report + // nothing (clients fall back to updated_at for display). + archived_at: + fields.archivedAt === undefined ? undefined : new Date(fields.archivedAt).toISOString(), busy: facts.busy, main_turn_active: facts.mainTurnActive, pending_interaction: facts.pendingInteraction, diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index a88b63a318..2ac8590b85 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -159,6 +159,9 @@ const v2SessionSchema = z.object({ created_at: z.number().int(), updated_at: z.number().int(), archived: z.boolean(), + /** Unix ms; null when absent (never archived, or archived before the + * field existed — clients fall back to updated_at for display). */ + archived_at: z.number().int().nullable(), }), activity: z.object({ status: v2ActivityStatusSchema }), git: v2GitDomainSchema.optional(), @@ -492,6 +495,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): created_at: summary.createdAt, updated_at: summary.updatedAt, archived: summary.archived, + archived_at: summary.archivedAt ?? null, }, activity: { status: mapActivityStatus(factsOf(summary.id), summary.lastTurnReason) }, git: diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 9938d3be32..cd1e2ebebe 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -234,6 +234,7 @@ describe('server /api/v2/sessions', () => { created_at: 3_000, updated_at: 5_000, archived: false, + archived_at: null, }); // Stubbed sessions are cold → always idle. expect(first.activity).toEqual({ status: 'idle' }); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 06ac78a390..9a25323aac 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -17,6 +17,7 @@ export const sessionSummarySchema = z.object({ createdAt: z.number(), updatedAt: z.number(), archived: z.boolean(), + archivedAt: z.number().optional(), custom: z.record(z.string(), z.unknown()).optional(), lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), }); diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index 71764ed563..e72d6642ce 100644 --- a/packages/klient/src/contract/session/metadata.ts +++ b/packages/klient/src/contract/session/metadata.ts @@ -27,6 +27,7 @@ export const sessionMetaSchema = z.object({ createdAt: z.number(), updatedAt: z.number(), archived: z.boolean(), + archivedAt: z.number().optional(), cwd: z.string().optional(), forkedFrom: z.string().optional(), agents: z.record(z.string(), agentMetaSchema).optional(), @@ -42,6 +43,7 @@ export const sessionMetaPatchSchema = z.object({ lastPrompt: z.string().optional(), updatedAt: z.number().optional(), archived: z.boolean().optional(), + archivedAt: z.number().optional(), cwd: z.string().optional(), forkedFrom: z.string().optional(), agents: z.record(z.string(), agentMetaSchema).optional(), @@ -59,6 +61,7 @@ export const sessionMetaKeySchema = z.enum([ 'createdAt', 'updatedAt', 'archived', + 'archivedAt', 'cwd', 'forkedFrom', 'agents', diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index 8d10164e8c..4417ba39b4 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -102,6 +102,9 @@ export const sessionSchema = z.object({ * reason is cancelled/failed). */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + /** When the session was archived (ISO 8601); absent for sessions archived + * before the field existed — clients fall back to `updated_at`. */ + archived_at: isoDateTimeSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(),