Переглянути джерело

feat(session): add viewed state

Kit Langton 23 годин тому
батько
коміт
f6b7181916
30 змінених файлів з 1708 додано та 472 видалено
  1. 93 79
      packages/client/src/effect/api/api.ts
  2. 82 74
      packages/client/src/effect/generated/client.ts
  3. 13 0
      packages/client/src/promise/generated/client.ts
  4. 38 4
      packages/client/src/promise/generated/types.ts
  5. 11 1
      packages/client/test/effect.test.ts
  6. 5 0
      packages/client/test/promise.test.ts
  7. 152 44
      packages/core/schema.json
  8. 2 0
      packages/core/src/database/migration.gen.ts
  9. 14 0
      packages/core/src/database/migration/20260815182818_session_viewed_state.ts
  10. 2 0
      packages/core/src/database/schema.gen.ts
  11. 17 0
      packages/core/src/session.ts
  12. 2 0
      packages/core/src/session/info.ts
  13. 1 0
      packages/core/src/session/message-updater.ts
  14. 38 3
      packages/core/src/session/projector.ts
  15. 2 0
      packages/core/src/session/sql.ts
  16. 4 0
      packages/core/src/session/transfer.ts
  17. 22 0
      packages/core/test/database-migration.test.ts
  18. 16 3
      packages/core/test/session-create.test.ts
  19. 114 0
      packages/core/test/session-view.test.ts
  20. 2 0
      packages/core/test/v1-migration.test.ts
  21. 329 85
      packages/protocol/openapi.json
  22. 13 0
      packages/protocol/src/groups/session.ts
  23. 8 0
      packages/schema/src/session-event.ts
  24. 2 0
      packages/schema/src/session.ts
  25. 21 9
      packages/schema/test/contract-hygiene.test.ts
  26. 1 0
      packages/schema/test/event-manifest.test.ts
  27. 16 0
      packages/server/src/handlers/session.ts
  28. 30 0
      packages/server/test/fetch.test.ts
  29. 329 85
      packages/www/openapi.json
  30. 329 85
      packages/www/public/openapi.json

+ 93 - 79
packages/client/src/effect/api/api.ts

@@ -146,34 +146,38 @@ export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect
 
 export type Endpoint5_6Input = { readonly sessionID: Session.ID }
 export type Endpoint5_6Output = void
-export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
+export type SessionViewOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
 
-export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
-export type Endpoint5_7Output = Session.Info
-export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
+export type Endpoint5_7Input = { readonly sessionID: Session.ID }
+export type Endpoint5_7Output = void
+export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
 
-export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
-export type Endpoint5_8Output = void
-export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
+export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
+export type Endpoint5_8Output = Session.Info
+export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
 
-export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
+export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
 export type Endpoint5_9Output = void
-export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
+export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
 
-export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
+export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
 export type Endpoint5_10Output = void
-export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
+export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
 
-export type Endpoint5_11Input = {
+export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
+export type Endpoint5_11Output = void
+export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
+
+export type Endpoint5_12Input = {
   readonly sessionID: Session.ID
   readonly directory: AbsolutePath
   readonly workspaceID?: Workspace.ID | undefined
   readonly delivery?: SessionInbox.Delivery | undefined
 }
-export type Endpoint5_11Output = void
-export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
+export type Endpoint5_12Output = void
+export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
 
-export type Endpoint5_12Input = {
+export type Endpoint5_13Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly text: string
@@ -184,10 +188,10 @@ export type Endpoint5_12Input = {
   readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_12Output = SessionInbox.User
-export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
+export type Endpoint5_13Output = SessionInbox.User
+export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
 
-export type Endpoint5_13Input = {
+export type Endpoint5_14Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly command: string
@@ -200,19 +204,19 @@ export type Endpoint5_13Input = {
   readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_13Output = SessionInbox.User
-export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
+export type Endpoint5_14Output = SessionInbox.User
+export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
 
-export type Endpoint5_14Input = {
+export type Endpoint5_15Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly skill: Skill.ID
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_14Output = void
-export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
+export type Endpoint5_15Output = void
+export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
 
-export type Endpoint5_15Input = {
+export type Endpoint5_16Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly text: string
@@ -221,97 +225,97 @@ export type Endpoint5_15Input = {
   readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_15Output = SessionInbox.Synthetic
-export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
+export type Endpoint5_16Output = SessionInbox.Synthetic
+export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
 
-export type Endpoint5_16Input = {
+export type Endpoint5_17Input = {
   readonly sessionID: Session.ID
   readonly id?: Event.ID | undefined
   readonly command: string
 }
-export type Endpoint5_16Output = void
-export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
+export type Endpoint5_17Output = void
+export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
 
-export type Endpoint5_17Input = {
+export type Endpoint5_18Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly delivery?: SessionInbox.Delivery | undefined
 }
-export type Endpoint5_17Output = SessionInbox.Compaction
-export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
+export type Endpoint5_18Output = SessionInbox.Compaction
+export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
 
-export type Endpoint5_18Input = { readonly sessionID: Session.ID }
-export type Endpoint5_18Output = void
-export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
+export type Endpoint5_19Input = { readonly sessionID: Session.ID }
+export type Endpoint5_19Output = void
+export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
 
-export type Endpoint5_19Input = {
+export type Endpoint5_20Input = {
   readonly sessionID: Session.ID
   readonly messageID: SessionMessage.ID
   readonly files?: boolean | undefined
 }
-export type Endpoint5_19Output = Session.Revert
-export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
-
-export type Endpoint5_20Input = { readonly sessionID: Session.ID }
-export type Endpoint5_20Output = void
-export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
+export type Endpoint5_20Output = Session.Revert
+export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
 
 export type Endpoint5_21Input = { readonly sessionID: Session.ID }
 export type Endpoint5_21Output = void
-export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
+export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
 
 export type Endpoint5_22Input = { readonly sessionID: Session.ID }
-export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
-export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
+export type Endpoint5_22Output = void
+export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
 
 export type Endpoint5_23Input = { readonly sessionID: Session.ID }
-export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
-export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
+export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
+export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
 
-export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
-export type Endpoint5_24Output = void
-export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
+export type Endpoint5_24Input = { readonly sessionID: Session.ID }
+export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
+export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
 
 export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
 export type Endpoint5_25Output = void
-export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
+export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
 
 export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
 export type Endpoint5_26Output = void
-export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
+export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
+
+export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
+export type Endpoint5_27Output = void
+export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
 
-export type Endpoint5_27Input = { readonly sessionID: Session.ID }
-export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
+export type Endpoint5_28Input = { readonly sessionID: Session.ID }
+export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
 export type SessionInstructionsEntryListOperation<E = never> = (
-  input: Endpoint5_27Input,
-) => Effect.Effect<Endpoint5_27Output, E>
+  input: Endpoint5_28Input,
+) => Effect.Effect<Endpoint5_28Output, E>
 
-export type Endpoint5_28Input = {
+export type Endpoint5_29Input = {
   readonly sessionID: Session.ID
   readonly key: InstructionEntry.Key
   readonly value: Schema.Json
 }
-export type Endpoint5_28Output = void
-export type SessionInstructionsEntryPutOperation<E = never> = (
-  input: Endpoint5_28Input,
-) => Effect.Effect<Endpoint5_28Output, E>
-
-export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
 export type Endpoint5_29Output = void
-export type SessionInstructionsEntryRemoveOperation<E = never> = (
+export type SessionInstructionsEntryPutOperation<E = never> = (
   input: Endpoint5_29Input,
 ) => Effect.Effect<Endpoint5_29Output, E>
 
-export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
-export type Endpoint5_30Output = { readonly text: string }
-export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
+export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
+export type Endpoint5_30Output = void
+export type SessionInstructionsEntryRemoveOperation<E = never> = (
+  input: Endpoint5_30Input,
+) => Effect.Effect<Endpoint5_30Output, E>
+
+export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
+export type Endpoint5_31Output = { readonly text: string }
+export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
 
-export type Endpoint5_31Input = {
+export type Endpoint5_32Input = {
   readonly sessionID: Session.ID
   readonly after?: Event.Seq | undefined
   readonly follow?: boolean | undefined
 }
-export type Endpoint5_31Output =
+export type Endpoint5_32Output =
   | (
       | {
           readonly id: Event.ID
@@ -386,6 +390,15 @@ export type Endpoint5_31Output =
           readonly id: Event.ID
           readonly created: number
           readonly metadata?: { readonly [x: string]: unknown } | undefined
+          readonly type: "session.viewed"
+          readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
+          readonly location?: Location.Ref | undefined
+          readonly data: { readonly sessionID: Session.ID }
+        }
+      | {
+          readonly id: Event.ID
+          readonly created: DateTime.Utc
+          readonly metadata?: { readonly [x: string]: unknown } | undefined
           readonly type: "session.deleted"
           readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
           readonly location?: Location.Ref | undefined
@@ -900,19 +913,19 @@ export type Endpoint5_31Output =
         }
     )
   | EventLog.Synced
-export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
+export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
 
-export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
-export type Endpoint5_32Output = void
-export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
-
-export type Endpoint5_33Input = { readonly sessionID: Session.ID }
+export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
 export type Endpoint5_33Output = void
-export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
+export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
+
+export type Endpoint5_34Input = { readonly sessionID: Session.ID }
+export type Endpoint5_34Output = void
+export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
 
-export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
-export type Endpoint5_34Output = SessionMessage.Info
-export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
+export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
+export type Endpoint5_35Output = SessionMessage.Info
+export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
 
 export interface SessionApi<E = never> {
   readonly list: SessionListOperation<E>
@@ -921,6 +934,7 @@ export interface SessionApi<E = never> {
   readonly export: SessionExportOperation<E>
   readonly active: SessionActiveOperation<E>
   readonly get: SessionGetOperation<E>
+  readonly view: SessionViewOperation<E>
   readonly remove: SessionRemoveOperation<E>
   readonly fork: SessionForkOperation<E>
   readonly switchAgent: SessionSwitchAgentOperation<E>

+ 82 - 74
packages/client/src/effect/generated/client.ts

@@ -86,6 +86,8 @@ import type {
   Endpoint5_33Output,
   Endpoint5_34Input,
   Endpoint5_34Output,
+  Endpoint5_35Input,
+  Endpoint5_35Output,
   Endpoint6_0Input,
   Endpoint6_0Output,
   Endpoint7_0Input,
@@ -355,48 +357,53 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
 
 const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
   preserveEffect<Endpoint5_6Output>()(
-    raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
+    raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
 const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
   preserveEffect<Endpoint5_7Output>()(
-    raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
-      Effect.mapError(mapClientError),
-      Effect.map((value) => value.data),
-    ),
+    raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
 const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
   preserveEffect<Endpoint5_8Output>()(
-    raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
+    raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
       Effect.mapError(mapClientError),
+      Effect.map((value) => value.data),
     ),
   )
 
 const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
   preserveEffect<Endpoint5_9Output>()(
-    raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
+    raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
   preserveEffect<Endpoint5_10Output>()(
-    raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
+    raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
   preserveEffect<Endpoint5_11Output>()(
+    raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
+      Effect.mapError(mapClientError),
+    ),
+  )
+
+const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
+  preserveEffect<Endpoint5_12Output>()(
     raw["session.move"]({
       params: { sessionID: input["sessionID"] },
       payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
-  preserveEffect<Endpoint5_12Output>()(
+const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
+  preserveEffect<Endpoint5_13Output>()(
     raw["session.prompt"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -415,8 +422,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
     ),
   )
 
-const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
-  preserveEffect<Endpoint5_13Output>()(
+const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
+  preserveEffect<Endpoint5_14Output>()(
     raw["session.command"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -437,16 +444,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
     ),
   )
 
-const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
-  preserveEffect<Endpoint5_14Output>()(
+const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
+  preserveEffect<Endpoint5_15Output>()(
     raw["session.skill"]({
       params: { sessionID: input["sessionID"] },
       payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
-  preserveEffect<Endpoint5_15Output>()(
+const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
+  preserveEffect<Endpoint5_16Output>()(
     raw["session.synthetic"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -463,16 +470,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
     ),
   )
 
-const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
-  preserveEffect<Endpoint5_16Output>()(
+const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
+  preserveEffect<Endpoint5_17Output>()(
     raw["session.shell"]({
       params: { sessionID: input["sessionID"] },
       payload: { id: input["id"], command: input["command"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
-  preserveEffect<Endpoint5_17Output>()(
+const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
+  preserveEffect<Endpoint5_18Output>()(
     raw["session.compact"]({
       params: { sessionID: input["sessionID"] },
       payload: { id: input["id"], delivery: input["delivery"] },
@@ -482,13 +489,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
     ),
   )
 
-const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
-  preserveEffect<Endpoint5_18Output>()(
+const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
+  preserveEffect<Endpoint5_19Output>()(
     raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
-  preserveEffect<Endpoint5_19Output>()(
+const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
+  preserveEffect<Endpoint5_20Output>()(
     raw["session.revert.stage"]({
       params: { sessionID: input["sessionID"] },
       payload: { messageID: input["messageID"], files: input["files"] },
@@ -498,27 +505,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
     ),
   )
 
-const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
-  preserveEffect<Endpoint5_20Output>()(
-    raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
-  )
-
 const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
   preserveEffect<Endpoint5_21Output>()(
-    raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
+    raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
 const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
   preserveEffect<Endpoint5_22Output>()(
-    raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
-      Effect.mapError(mapClientError),
-      Effect.map((value) => value.data),
-    ),
+    raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
 const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
   preserveEffect<Endpoint5_23Output>()(
-    raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
@@ -526,58 +525,66 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
 
 const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
   preserveEffect<Endpoint5_24Output>()(
-    raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
+    raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
+      Effect.map((value) => value.data),
     ),
   )
 
 const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
   preserveEffect<Endpoint5_25Output>()(
-    raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
+    raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
   preserveEffect<Endpoint5_26Output>()(
-    raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
+    raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
   preserveEffect<Endpoint5_27Output>()(
-    raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
-      Effect.map((value) => value.data),
     ),
   )
 
 const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
   preserveEffect<Endpoint5_28Output>()(
+    raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
+      Effect.mapError(mapClientError),
+      Effect.map((value) => value.data),
+    ),
+  )
+
+const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
+  preserveEffect<Endpoint5_29Output>()(
     raw["session.instructions.entry.put"]({
       params: { sessionID: input["sessionID"], key: input["key"] },
       payload: { value: input["value"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
-  preserveEffect<Endpoint5_29Output>()(
+const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
+  preserveEffect<Endpoint5_30Output>()(
     raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
-const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
-  preserveEffect<Endpoint5_30Output>()(
+const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
+  preserveEffect<Endpoint5_31Output>()(
     raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
-  preserveStream<Endpoint5_31Output>()(
+const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
+  preserveStream<Endpoint5_32Output>()(
     Stream.unwrap(
       raw["session.log"]({
         params: { sessionID: input["sessionID"] },
@@ -589,21 +596,21 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
     ),
   )
 
-const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
-  preserveEffect<Endpoint5_32Output>()(
+const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
+  preserveEffect<Endpoint5_33Output>()(
     raw["session.interrupt"]({
       params: { sessionID: input["sessionID"] },
       query: { continue: input["continue"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
-  preserveEffect<Endpoint5_33Output>()(
+const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
+  preserveEffect<Endpoint5_34Output>()(
     raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
-  preserveEffect<Endpoint5_34Output>()(
+const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
+  preserveEffect<Endpoint5_35Output>()(
     raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
@@ -617,28 +624,29 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
   export: Endpoint5_3(raw),
   active: Endpoint5_4(raw),
   get: Endpoint5_5(raw),
-  remove: Endpoint5_6(raw),
-  fork: Endpoint5_7(raw),
-  switchAgent: Endpoint5_8(raw),
-  switchModel: Endpoint5_9(raw),
-  rename: Endpoint5_10(raw),
-  move: Endpoint5_11(raw),
-  prompt: Endpoint5_12(raw),
-  command: Endpoint5_13(raw),
-  skill: Endpoint5_14(raw),
-  synthetic: Endpoint5_15(raw),
-  shell: Endpoint5_16(raw),
-  compact: Endpoint5_17(raw),
-  wait: Endpoint5_18(raw),
-  revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
-  context: Endpoint5_22(raw),
-  inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
-  instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
-  generate: Endpoint5_30(raw),
-  log: Endpoint5_31(raw),
-  interrupt: Endpoint5_32(raw),
-  background: Endpoint5_33(raw),
-  message: Endpoint5_34(raw),
+  view: Endpoint5_6(raw),
+  remove: Endpoint5_7(raw),
+  fork: Endpoint5_8(raw),
+  switchAgent: Endpoint5_9(raw),
+  switchModel: Endpoint5_10(raw),
+  rename: Endpoint5_11(raw),
+  move: Endpoint5_12(raw),
+  prompt: Endpoint5_13(raw),
+  command: Endpoint5_14(raw),
+  skill: Endpoint5_15(raw),
+  synthetic: Endpoint5_16(raw),
+  shell: Endpoint5_17(raw),
+  compact: Endpoint5_18(raw),
+  wait: Endpoint5_19(raw),
+  revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
+  context: Endpoint5_23(raw),
+  inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
+  instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
+  generate: Endpoint5_31(raw),
+  log: Endpoint5_32(raw),
+  interrupt: Endpoint5_33(raw),
+  background: Endpoint5_34(raw),
+  message: Endpoint5_35(raw),
 })
 
 const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>

+ 13 - 0
packages/client/src/promise/generated/client.ts

@@ -22,6 +22,8 @@ import type {
   SessionActiveOutput,
   SessionGetInput,
   SessionGetOutput,
+  SessionViewInput,
+  SessionViewOutput,
   SessionRemoveInput,
   SessionRemoveOutput,
   SessionForkInput,
@@ -526,6 +528,17 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ).then((value) => value.data),
+      view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
+        request<SessionViewOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
+            successStatus: 204,
+            declaredStatuses: [404, 401, 400],
+            empty: true,
+          },
+          requestOptions,
+        ),
       remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
         request<SessionRemoveOutput>(
           {

+ 38 - 4
packages/client/src/promise/generated/types.ts

@@ -479,6 +479,16 @@ export type SessionRenamed = {
   data: { sessionID: string; title: string }
 }
 
+export type SessionViewed = {
+  id: string
+  created: number
+  metadata?: { [x: string]: any }
+  type: "session.viewed"
+  durable: { aggregateID: string; seq: number; version: 1 }
+  location?: LocationRef
+  data: { sessionID: string }
+}
+
 export type SessionDeleted = {
   id: string
   created: number
@@ -1510,7 +1520,7 @@ export type SessionInfo = {
   model?: ModelRef
   cost: MoneyUSD
   tokens: TokenUsageInfo
-  time: { created: number; updated: number; archived?: number }
+  time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
   title?: string
   location: LocationRef
   subpath?: string
@@ -1923,6 +1933,7 @@ export type SessionEventDurable =
   | SessionModelSelected
   | SessionMoved
   | SessionRenamed
+  | SessionViewed
   | SessionDeleted
   | SessionForked
   | SessionInboxDelivered
@@ -2013,6 +2024,7 @@ export type V2Event =
   | SessionModelSelected
   | SessionMoved
   | SessionRenamed
+  | SessionViewed
   | SessionUsageUpdated
   | SessionDeleted
   | SessionForked
@@ -2476,7 +2488,13 @@ export type SessionImportInput = {
         readonly reasoning: number
         readonly cache: { readonly read: number; readonly write: number }
       }
-      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly time: {
+        readonly created: number
+        readonly updated: number
+        readonly idle?: number
+        readonly viewed?: number
+        readonly archived?: number
+      }
       readonly title?: string
       readonly location: { readonly directory: string; readonly workspaceID?: string }
       readonly subpath?: string
@@ -2743,7 +2761,13 @@ export type SessionImportInput = {
         readonly reasoning: number
         readonly cache: { readonly read: number; readonly write: number }
       }
-      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly time: {
+        readonly created: number
+        readonly updated: number
+        readonly idle?: number
+        readonly viewed?: number
+        readonly archived?: number
+      }
       readonly title?: string
       readonly location: { readonly directory: string; readonly workspaceID?: string }
       readonly subpath?: string
@@ -3010,7 +3034,13 @@ export type SessionImportInput = {
         readonly reasoning: number
         readonly cache: { readonly read: number; readonly write: number }
       }
-      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly time: {
+        readonly created: number
+        readonly updated: number
+        readonly idle?: number
+        readonly viewed?: number
+        readonly archived?: number
+      }
       readonly title?: string
       readonly location: { readonly directory: string; readonly workspaceID?: string }
       readonly subpath?: string
@@ -3274,6 +3304,10 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
 
 export type SessionGetOutput = { data: SessionInfo }["data"]
 
+export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionViewOutput = void
+
 export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
 
 export type SessionRemoveOutput = void

+ 11 - 1
packages/client/test/effect.test.ts

@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
 
 test("session methods retain decoded Effect inputs and outputs", async () => {
   const logQueries: Array<Record<string, string>> = []
+  const requests: Array<{ method: string; url: string }> = []
   const httpClient = HttpClient.make((request) => {
     const url = request.url
+    requests.push({ method: request.method, url })
     if (url.includes("/log")) {
       logQueries.push(Object.fromEntries(request.urlParams.params))
       return Effect.succeed(
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
     const created = yield* client.session.create({
       location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
     })
+    yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
     yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
     yield* client.session.switchModel({
       sessionID: Session.ID.make("ses_test"),
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
     return { page, active, created, admitted, context, log, message }
   }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
 
-  expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
+  const listed = result.page.data[0]
+  if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
+  expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
+  expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
+  expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
   expect(result.active).toEqual({ ses_test: { type: "running" } })
   expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
   expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -217,6 +224,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
   expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
   expect(result.context).toEqual([])
   expect(logQueries[0]).toEqual({ after: "0" })
+  expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
   const logged = Array.from(result.log)
   expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
   expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
@@ -260,6 +268,8 @@ const session = {
     time: {
       created: 1_717_171_717_000,
       updated: 1_717_171_717_000,
+      idle: 1_717_171_717_002,
+      viewed: 1_717_171_717_001,
     },
     title: "Test",
     location: { directory: "/tmp/project" },

+ 5 - 0
packages/client/test/promise.test.ts

@@ -539,6 +539,7 @@ test("session methods use the public HTTP contract", async () => {
   const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
   const active = await client.session.active()
   const created = await client.session.create({ location: { directory: "/tmp/project" } })
+  await client.session.view({ sessionID: "ses_test" })
   await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
   await client.session.switchModel({
     sessionID: "ses_test",
@@ -565,6 +566,7 @@ test("session methods use the public HTTP contract", async () => {
   const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
 
   expect(page.cursor.next).toBe("next")
+  expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
   expect(active).toEqual({ ses_test: { type: "running" } })
   expect(created.id).toBe("ses_test")
   expect(admitted.id).toBe("msg_test")
@@ -577,6 +579,7 @@ test("session methods use the public HTTP contract", async () => {
     ["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
     ["GET", "http://localhost:3000/api/session/active"],
     ["POST", "http://localhost:3000/api/session"],
+    ["POST", "http://localhost:3000/api/session/ses_test/view"],
     ["POST", "http://localhost:3000/api/session/ses_test/agent"],
     ["POST", "http://localhost:3000/api/session/ses_test/model"],
     ["POST", "http://localhost:3000/api/session/ses_test/prompt"],
@@ -651,6 +654,8 @@ const session = {
     time: {
       created: 1_717_171_717_000,
       updated: 1_717_171_717_000,
+      idle: 1_717_171_717_002,
+      viewed: 1_717_171_717_001,
     },
     title: "Test",
     location: { directory: "/tmp/project" },

+ 152 - 44
packages/core/schema.json

@@ -1,8 +1,10 @@
 {
   "version": "7",
   "dialect": "sqlite",
-  "id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
-  "prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
+  "id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
+  "prevIds": [
+    "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"
+  ],
   "ddl": [
     {
       "name": "account_state",
@@ -1350,6 +1352,26 @@
       "entityType": "columns",
       "table": "session_v2"
     },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_idle",
+      "entityType": "columns",
+      "table": "session_v2"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_viewed",
+      "entityType": "columns",
+      "table": "session_v2"
+    },
     {
       "type": "integer",
       "notNull": false,
@@ -1481,9 +1503,13 @@
       "table": "worktree"
     },
     {
-      "columns": ["active_account_id"],
+      "columns": [
+        "active_account_id"
+      ],
       "tableTo": "account",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "SET NULL",
       "nameExplicit": false,
@@ -1492,9 +1518,13 @@
       "table": "account_state"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "tableTo": "event_sequence",
-      "columnsTo": ["aggregate_id"],
+      "columnsTo": [
+        "aggregate_id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1503,9 +1533,13 @@
       "table": "event"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1514,9 +1548,13 @@
       "table": "permission"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1525,9 +1563,13 @@
       "table": "project_directory"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1536,9 +1578,13 @@
       "table": "instruction_entry"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1547,9 +1593,13 @@
       "table": "instruction_state"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1558,9 +1608,13 @@
       "table": "session_inbox"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1569,9 +1623,13 @@
       "table": "session_message"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1580,9 +1638,13 @@
       "table": "session_pending"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1591,9 +1653,13 @@
       "table": "session_v2"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1602,133 +1668,175 @@
       "table": "worktree"
     },
     {
-      "columns": ["email", "url"],
+      "columns": [
+        "email",
+        "url"
+      ],
       "nameExplicit": false,
       "name": "control_account_pk",
       "entityType": "pks",
       "table": "control_account"
     },
     {
-      "columns": ["project_id", "directory"],
+      "columns": [
+        "project_id",
+        "directory"
+      ],
       "nameExplicit": false,
       "name": "project_directory_pk",
       "entityType": "pks",
       "table": "project_directory"
     },
     {
-      "columns": ["session_id", "key"],
+      "columns": [
+        "session_id",
+        "key"
+      ],
       "nameExplicit": false,
       "name": "instruction_entry_pk",
       "entityType": "pks",
       "table": "instruction_entry"
     },
     {
-      "columns": ["project_id", "directory"],
+      "columns": [
+        "project_id",
+        "directory"
+      ],
       "nameExplicit": false,
       "name": "worktree_pk",
       "entityType": "pks",
       "table": "worktree"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_state_pk",
       "table": "account_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_pk",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "credential_pk",
       "table": "credential",
       "entityType": "pks"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "nameExplicit": false,
       "name": "event_sequence_pk",
       "table": "event_sequence",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "event_pk",
       "table": "event",
       "entityType": "pks"
     },
     {
-      "columns": ["key"],
+      "columns": [
+        "key"
+      ],
       "nameExplicit": false,
       "name": "kv_pk",
       "table": "kv",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "permission_pk",
       "table": "permission",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "project_pk",
       "table": "project",
       "entityType": "pks"
     },
     {
-      "columns": ["hash"],
+      "columns": [
+        "hash"
+      ],
       "nameExplicit": false,
       "name": "instruction_blob_pk",
       "table": "instruction_blob",
       "entityType": "pks"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "nameExplicit": false,
       "name": "instruction_state_pk",
       "table": "instruction_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_inbox_pk",
       "table": "session_inbox",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_message_pk",
       "table": "session_message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_pending_pk",
       "table": "session_pending",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_v2_pk",
       "table": "session_v2",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "workspace_pk",
       "table": "workspace",
@@ -2024,4 +2132,4 @@
     }
   ],
   "renames": []
-}
+}

+ 2 - 0
packages/core/src/database/migration.gen.ts

@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
 import m41 from "./migration/20260811161259_execution_claim_attempts.js"
 import m42 from "./migration/20260812181746_session_inbox.js"
 import m43 from "./migration/20260812213948_worktree.js"
+import m44 from "./migration/20260815182818_session_viewed_state.js"
 
 export const migrations = [
   m00,
@@ -89,4 +90,5 @@ export const migrations = [
   m41,
   m42,
   m43,
+  m44,
 ] satisfies DatabaseMigration.Migration[]

+ 14 - 0
packages/core/src/database/migration/20260815182818_session_viewed_state.ts

@@ -0,0 +1,14 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration.js"
+
+const migration: DatabaseMigration.Migration = {
+  id: "20260815182818_session_viewed_state",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
+      yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
+    })
+  },
+}
+
+export default migration

+ 2 - 0
packages/core/src/database/schema.gen.ts

@@ -209,6 +209,8 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
           \`model\` text,
           \`time_created\` integer NOT NULL,
           \`time_updated\` integer NOT NULL,
+          \`time_idle\` integer,
+          \`time_viewed\` integer,
           \`time_compacting\` integer,
           \`time_archived\` integer,
           \`time_suspended\` integer,

+ 17 - 0
packages/core/src/session.ts

@@ -165,6 +165,7 @@ export interface Interface {
     input: ForkInput,
   ) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
   readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
+  readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
   readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   readonly messages: (input: {
     sessionID: SessionSchema.ID
@@ -307,6 +308,7 @@ const layer = Layer.effect(
     const scope = yield* Scope.Scope
     const activeShells = new Set<SessionSchema.ID>()
     const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
+    const viewLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
     const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
       const location = Location.Ref.make({
         directory: session.location.directory,
@@ -447,6 +449,21 @@ const layer = Layer.effect(
         if (!session) return yield* new NotFoundError({ sessionID })
         return session
       }),
+      view: Effect.fn("Session.view")((input) =>
+        viewLocks.withLock(input.sessionID)(
+          Effect.gen(function* () {
+            const row = yield* db
+              .select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
+              .from(SessionTable)
+              .where(eq(SessionTable.id, input.sessionID))
+              .get()
+              .pipe(Effect.orDie)
+            if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
+            if (row.idle === null || row.viewed === row.idle) return
+            yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
+          }),
+        ),
+      ),
       remove: Effect.fn("Session.remove")(function* (sessionID) {
         const session = yield* result.get(sessionID)
         yield* execution.interrupt(sessionID)

+ 2 - 0
packages/core/src/session/info.ts

@@ -53,6 +53,8 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
     time: {
       created: DateTime.makeUnsafe(row.time_created),
       updated: DateTime.makeUnsafe(row.time_updated),
+      idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
+      viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
       archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
     },
   })

+ 1 - 0
packages/core/src/session/message-updater.ts

@@ -60,6 +60,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
     Match.type<SessionEvent.DurableEvent>(),
     Match.discriminatorsExhaustive("type")({
       "session.created": () => Effect.void,
+      "session.viewed": () => Effect.void,
       "session.usage.recorded": () => Effect.void,
       "session.agent.selected": (event) => {
         return Effect.gen(function* () {

+ 38 - 3
packages/core/src/session/projector.ts

@@ -391,6 +391,30 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
     .pipe(Effect.orDie)
 }
 
+function projectIdle(
+  db: DatabaseService,
+  event:
+    | typeof SessionEvent.Execution.Succeeded.Type
+    | typeof SessionEvent.Execution.Failed.Type
+    | typeof SessionEvent.Execution.Interrupted.Type,
+) {
+  return Effect.gen(function* () {
+    yield* run(db, event)
+    if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
+    const time = DateTime.toEpochMillis(event.created)
+    yield* db
+      .update(SessionTable)
+      .set({
+        // Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
+        time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
+        time_updated: sql`${SessionTable.time_updated}`,
+      })
+      .where(eq(SessionTable.id, event.data.sessionID))
+      .run()
+      .pipe(Effect.orDie)
+  })
+}
+
 const layer = Layer.effectDiscard(
   Effect.gen(function* () {
     const bus = yield* Bus.Service
@@ -512,6 +536,17 @@ const layer = Layer.effectDiscard(
         .run()
         .pipe(Effect.orDie),
     )
+    yield* bus.project(SessionEvent.Viewed, (event) =>
+      db
+        .update(SessionTable)
+        .set({
+          time_viewed: sql`${SessionTable.time_idle}`,
+          time_updated: sql`${SessionTable.time_updated}`,
+        })
+        .where(eq(SessionTable.id, event.data.sessionID))
+        .run()
+        .pipe(Effect.orDie),
+    )
     yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
     yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
     yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -580,9 +615,9 @@ const layer = Layer.effectDiscard(
         delivery: event.data.delivery,
       }),
     )
-    yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
-    yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
-    yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
+    yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
+    yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
+    yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
     yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
       Effect.gen(function* () {
         yield* run(db, event)

+ 2 - 0
packages/core/src/session/sql.ts

@@ -56,6 +56,8 @@ export const SessionTable = sqliteTable(
       variant?: string
     }>(),
     ...Timestamps,
+    time_idle: integer(),
+    time_viewed: integer(),
     time_compacting: integer(),
     time_archived: integer(),
     /** The execution claim timestamp (historical column name; see SessionStore.claim). */

+ 4 - 0
packages/core/src/session/transfer.ts

@@ -117,6 +117,10 @@ const layer = Layer.effect(
                       tokens_cache_write: input.data.info.tokens.cache.write,
                       time_created: DateTime.toEpochMillis(input.data.info.time.created),
                       time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
+                      time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
+                      time_viewed: input.data.info.time.viewed
+                        ? DateTime.toEpochMillis(input.data.info.time.viewed)
+                        : null,
                       time_archived: input.data.info.time.archived
                         ? DateTime.toEpochMillis(input.data.info.time.archived)
                         : null,

+ 22 - 0
packages/core/test/database-migration.test.ts

@@ -13,6 +13,7 @@ import { tmpdir } from "./fixture/tmpdir"
 import type { SqlClient } from "effect/unstable/sql/SqlClient"
 import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
 import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
+import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260815182818_session_viewed_state"
 import { Global } from "@opencode-ai/util/global"
 
 const run = <A, E>(
@@ -73,6 +74,27 @@ describe("DatabaseMigration", () => {
     )
   })
 
+  test("adds nullable attention state to existing sessions", async () => {
+    await run(
+      Effect.gen(function* () {
+        const db = yield* makeDb
+        yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
+        yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
+
+        yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
+        yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
+
+        expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed FROM session_v2`)).toEqual({
+          id: "ses_existing",
+          title: "Existing",
+          time_idle: null,
+          time_viewed: null,
+        })
+        expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
+      }),
+    )
+  })
+
   test("rejects a non-empty database without a session table", async () => {
     await expect(
       run(

+ 16 - 3
packages/core/test/session-create.test.ts

@@ -840,7 +840,15 @@ describe("SessionTransfer", () => {
 
       const imported = yield* transfer.import({
         data: {
-          info: { ...template, id: sessionID },
+          info: {
+            ...template,
+            id: sessionID,
+            time: {
+              ...template.time,
+              idle: DateTime.makeUnsafe(200),
+              viewed: DateTime.makeUnsafe(150),
+            },
+          },
           messages: [
             {
               id: sourceMessageID,
@@ -863,13 +871,18 @@ describe("SessionTransfer", () => {
       const messages = yield* session.messages({ sessionID, order: "asc" })
 
       expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
+      expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
       expect(messages).toMatchObject([
         { id: sourceMessageID, type: "user", text: "Imported message" },
         { id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
       ])
       expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
-      expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
-      expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
+      const exported = yield* transfer.export({ sessionID })
+      expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
+      expect(exported.messages).toEqual(messages)
+      const sanitized = yield* transfer.export({ sessionID, sanitize: true })
+      expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
+      expect(sanitized.messages).toMatchObject([
         { id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
         { id: errorMessageID, error: { type: "test_error", message: "Original error" } },
       ])

+ 114 - 0
packages/core/test/session-view.test.ts

@@ -0,0 +1,114 @@
+import { describe, expect } from "bun:test"
+import { Bus } from "@opencode-ai/core/bus"
+import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { EventTable } from "@opencode-ai/core/event/sql"
+import { Location } from "@opencode-ai/core/location"
+import { Project } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { Session } from "@opencode-ai/core/session"
+import { SessionEvent } from "@opencode-ai/core/session/event"
+import { SessionExecution } from "@opencode-ai/core/session/execution"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { SessionStore } from "@opencode-ai/core/session/store"
+import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { DateTime, Effect } from "effect"
+import { eq } from "drizzle-orm"
+import { testEffect } from "./lib/effect"
+import { globalProjectLayer } from "./lib/project"
+
+const it = testEffect(
+  AppNodeBuilder.build(
+    LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
+    [
+      [Bus.node, Bus.configured({ persist: true })],
+      [Project.node, globalProjectLayer],
+      [SessionExecution.node, SessionExecution.noopLayer],
+    ],
+  ),
+)
+const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
+
+describe("Session.view", () => {
+  it.effect("copies the latest idle time without changing session recency", () =>
+    Effect.gen(function* () {
+      const session = yield* Session.Service
+      const bus = yield* Bus.Service
+      const { db } = yield* Database.Service
+      const created = yield* session.create({ location })
+
+      expect(created.time.idle).toBeUndefined()
+      expect(created.time.viewed).toBeUndefined()
+
+      yield* Effect.all([session.view({ sessionID: created.id }), session.view({ sessionID: created.id })], {
+        concurrency: "unbounded",
+        discard: true,
+      })
+      expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
+
+      yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
+      const idle = yield* session.get(created.id)
+      expect(idle.time.idle).toBeDefined()
+      expect(idle.time.viewed).toBeUndefined()
+      expect(idle.time.updated).toEqual(created.time.updated)
+
+      yield* session.view({ sessionID: created.id })
+      const viewed = yield* session.get(created.id)
+      if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
+      expect(viewed.time.viewed).toEqual(viewed.time.idle)
+      expect(viewed.time.updated).toEqual(created.time.updated)
+      expect(
+        yield* db
+          .select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
+          .from(SessionTable)
+          .where(eq(SessionTable.id, created.id))
+          .get(),
+      ).toEqual({
+        idle: DateTime.toEpochMillis(viewed.time.idle),
+        viewed: DateTime.toEpochMillis(viewed.time.viewed),
+      })
+      expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
+
+      yield* session.view({ sessionID: created.id })
+      expect((yield* session.get(created.id)).time).toEqual(viewed.time)
+
+      yield* bus.publish(SessionEvent.Execution.Failed, {
+        sessionID: created.id,
+        error: { type: "unknown", message: "failed" },
+      })
+      const unread = yield* session.get(created.id)
+      if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
+      expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
+
+      yield* session.view({ sessionID: created.id })
+      expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
+
+      yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
+      expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
+
+      yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
+      const interrupted = yield* session.get(created.id)
+      if (!interrupted.time.idle || !interrupted.time.viewed)
+        return yield* Effect.die(new Error("Expected attention times"))
+      expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
+        DateTime.toEpochMillis(interrupted.time.viewed),
+      )
+      expect(
+        (yield* db
+          .select({ type: EventTable.type })
+          .from(EventTable)
+          .where(eq(EventTable.aggregate_id, created.id))
+          .all()).filter((event) => event.type === "session.viewed.1"),
+      ).toHaveLength(2)
+    }),
+  )
+
+  it.effect("rejects an unknown session", () =>
+    Effect.gen(function* () {
+      const session = yield* Session.Service
+      const sessionID = Session.ID.make("ses_missing_view")
+      expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
+    }),
+  )
+})

+ 2 - 0
packages/core/test/v1-migration.test.ts

@@ -60,6 +60,8 @@ const session = (
   model: null,
   time_created: 1,
   time_updated: 2,
+  time_idle: null,
+  time_viewed: null,
   time_compacting: 3,
   time_archived: null,
   time_suspended: null,

Різницю між файлами не показано, бо вона завелика
+ 329 - 85
packages/protocol/openapi.json


+ 13 - 0
packages/protocol/src/groups/session.ts

@@ -219,6 +219,19 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
         }),
       ),
     )
+    .add(
+      HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
+        params: { sessionID: Session.ID },
+        success: HttpApiSchema.NoContent,
+        error: SessionNotFoundError,
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.view",
+          summary: "View session",
+          description: "Mark the latest recorded idle transition as viewed.",
+        }),
+      ),
+    )
     .add(
       HttpApiEndpoint.delete("session.remove", "/api/session/:sessionID", {
         params: { sessionID: Session.ID },

+ 8 - 0
packages/schema/src/session-event.ts

@@ -105,6 +105,13 @@ export const Renamed = Event.durable({
 })
 export type Renamed = typeof Renamed.Type
 
+export const Viewed = Event.durable({
+  type: "session.viewed",
+  ...options,
+  schema: Base,
+})
+export type Viewed = typeof Viewed.Type
+
 export const UsageRecorded = Event.durable({
   type: "session.usage.recorded",
   ...options,
@@ -580,6 +587,7 @@ export const Definitions = Event.inventory(
   ModelSelected,
   Moved,
   Renamed,
+  Viewed,
   UsageUpdated,
   Deleted,
   Forked,

+ 2 - 0
packages/schema/src/session.ts

@@ -40,6 +40,8 @@ export const Info = Schema.Struct({
   time: Schema.Struct({
     created: DateTimeUtcFromMillis,
     updated: DateTimeUtcFromMillis,
+    idle: DateTimeUtcFromMillis.pipe(optional),
+    viewed: DateTimeUtcFromMillis.pipe(optional),
     archived: DateTimeUtcFromMillis.pipe(optional),
   }),
   title: Schema.String.pipe(optional),

+ 21 - 9
packages/schema/test/contract-hygiene.test.ts

@@ -54,17 +54,29 @@ describe("contract hygiene", () => {
       }),
     ).toEqual({ text: "completed" })
 
+    const info = Session.Info.make({
+      id: Session.ID.make("ses_untitled"),
+      projectID: Project.ID.make("global"),
+      cost: Money.USD.zero,
+      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+      time: {
+        created: DateTime.makeUnsafe(0),
+        updated: DateTime.makeUnsafe(0),
+        idle: undefined,
+        viewed: undefined,
+      },
+      title: undefined,
+      location: { directory: AbsolutePath.make("/project") },
+    })
+    const encoded = Schema.encodeSync(Session.Info)(info)
+    expect(encoded).not.toHaveProperty("title")
+    expect(encoded.time).toEqual({ created: 0, updated: 0 })
     expect(
       Schema.encodeSync(Session.Info)({
-        id: Session.ID.make("ses_untitled"),
-        projectID: Project.ID.make("global"),
-        cost: Money.USD.zero,
-        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-        time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
-        title: undefined,
-        location: { directory: AbsolutePath.make("/project") },
-      }),
-    ).not.toHaveProperty("title")
+        ...info,
+        time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
+      }).time,
+    ).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
   })
 
   test("session inbox items omit the internal enqueue sequence", () => {

+ 1 - 0
packages/schema/test/event-manifest.test.ts

@@ -83,6 +83,7 @@ describe("public event manifest", () => {
         "session.model.selected.1",
         "session.moved.1",
         "session.renamed.1",
+        "session.viewed.1",
         "session.usage.recorded.1",
         "session.forked.2",
         "session.inbox.delivered.1",

+ 16 - 0
packages/server/src/handlers/session.ts

@@ -180,6 +180,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
           }
         }),
       )
+      .handle(
+        "session.view",
+        Effect.fn(function* (ctx) {
+          yield* session.view({ sessionID: ctx.params.sessionID }).pipe(
+            Effect.catchTag(
+              "Session.NotFoundError",
+              (error) =>
+                new SessionNotFoundError({
+                  sessionID: error.sessionID,
+                  message: `Session not found: ${error.sessionID}`,
+                }),
+            ),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
       .handle(
         "session.remove",
         Effect.fn(function* (ctx) {

+ 30 - 0
packages/server/test/fetch.test.ts

@@ -52,6 +52,36 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
   }).pipe(Effect.scoped),
 )
 
+it.live("serves the session view operation and missing-session error", () =>
+  Effect.gen(function* () {
+    const handler = yield* ServerFetch.make(options)
+    const created = yield* Effect.promise(() =>
+      handler(
+        new Request("http://opencode.local/api/session", {
+          method: "POST",
+          headers: { "content-type": "application/json" },
+          body: "{}",
+        }),
+      ).then((response) => response.json()),
+    )
+    if (typeof created !== "object" || created === null || !("data" in created))
+      return yield* Effect.die(new Error("Expected a session response"))
+    const data = created.data
+    if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
+      return yield* Effect.die(new Error("Expected a session ID"))
+
+    const viewed = yield* Effect.promise(() =>
+      handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
+    )
+    expect(viewed.status).toBe(204)
+
+    const missing = yield* Effect.promise(() =>
+      handler(new Request("http://opencode.local/api/session/ses_missing_view/view", { method: "POST" })),
+    )
+    expect(missing.status).toBe(404)
+  }).pipe(Effect.scoped),
+)
+
 // Pins the eager-boot guarantee: the application layer is built before the handler returns, so
 // an aborted first request cannot interrupt layer construction and wedge every later request
 // (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).

Різницю між файлами не показано, бо вона завелика
+ 329 - 85
packages/www/openapi.json


Різницю між файлами не показано, бо вона завелика
+ 329 - 85
packages/www/public/openapi.json


Деякі файли не було показано, через те що забагато файлів було змінено