1
0
Эх сурвалжийг харах

feat: update session notices and skill reloads

Dax Raad 1 сар өмнө
parent
commit
fb884bb91e
29 өөрчлөгдсөн 304 нэмэгдсэн , 75 устгасан
  1. 14 0
      packages/client/src/generated/types.ts
  2. 15 7
      packages/core/src/filesystem/watcher.ts
  3. 1 6
      packages/core/src/flag/flag.ts
  4. 26 1
      packages/core/src/session.ts
  5. 1 0
      packages/core/src/session/message-updater.ts
  6. 44 8
      packages/core/src/skill.ts
  7. 1 9
      packages/core/test/filesystem/watcher.test.ts
  8. 2 0
      packages/core/test/move-session.test.ts
  9. 2 0
      packages/core/test/permission.test.ts
  10. 2 0
      packages/core/test/session-compact.test.ts
  11. 5 5
      packages/core/test/session-create.test.ts
  12. 2 0
      packages/core/test/session-history.test.ts
  13. 7 1
      packages/core/test/session-prompt.test.ts
  14. 2 0
      packages/core/test/session-runner-recorded.test.ts
  15. 2 0
      packages/core/test/session-runner.test.ts
  16. 2 0
      packages/core/test/session-wait.test.ts
  17. 54 2
      packages/core/test/skill.test.ts
  18. 0 1
      packages/desktop/src/main/server.ts
  19. 1 1
      packages/desktop/src/main/wsl/sidecar.ts
  20. 2 0
      packages/schema/src/event-manifest.ts
  21. 1 0
      packages/schema/src/session-event.ts
  22. 1 0
      packages/schema/src/session-message.ts
  23. 4 0
      packages/schema/src/skill.ts
  24. 39 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  25. 1 29
      packages/server/src/handlers/session.ts
  26. 4 0
      packages/tui/src/context/data.tsx
  27. 5 4
      packages/tui/src/routes/session/index.tsx
  28. 4 1
      packages/tui/src/routes/session/rows.ts
  29. 60 0
      packages/tui/test/cli/tui/session-rows.test.ts

+ 14 - 0
packages/client/src/generated/types.ts

@@ -664,6 +664,7 @@ export type SessionContextOutput = {
         readonly time: { readonly created: number }
         readonly time: { readonly created: number }
         readonly sessionID: string
         readonly sessionID: string
         readonly text: string
         readonly text: string
+        readonly description?: string
         readonly type: "synthetic"
         readonly type: "synthetic"
       }
       }
     | {
     | {
@@ -932,6 +933,7 @@ export type SessionHistoryOutput = {
           readonly sessionID: string
           readonly sessionID: string
           readonly messageID: string
           readonly messageID: string
           readonly text: string
           readonly text: string
+          readonly description?: string
         }
         }
       }
       }
     | {
     | {
@@ -1425,6 +1427,7 @@ export type SessionEventsOutput =
         readonly sessionID: string
         readonly sessionID: string
         readonly messageID: string
         readonly messageID: string
         readonly text: string
         readonly text: string
+        readonly description?: string
       }
       }
     }
     }
   | {
   | {
@@ -1824,6 +1827,7 @@ export type SessionMessageOutput = {
         readonly time: { readonly created: number }
         readonly time: { readonly created: number }
         readonly sessionID: string
         readonly sessionID: string
         readonly text: string
         readonly text: string
+        readonly description?: string
         readonly type: "synthetic"
         readonly type: "synthetic"
       }
       }
     | {
     | {
@@ -2004,6 +2008,7 @@ export type MessageListOutput = {
         readonly time: { readonly created: number }
         readonly time: { readonly created: number }
         readonly sessionID: string
         readonly sessionID: string
         readonly text: string
         readonly text: string
+        readonly description?: string
         readonly type: "synthetic"
         readonly type: "synthetic"
       }
       }
     | {
     | {
@@ -3523,6 +3528,7 @@ export type EventSubscribeOutput =
         readonly sessionID: string
         readonly sessionID: string
         readonly messageID: string
         readonly messageID: string
         readonly text: string
         readonly text: string
+        readonly description?: string
       }
       }
     }
     }
   | {
   | {
@@ -3983,6 +3989,14 @@ export type EventSubscribeOutput =
       readonly location?: { readonly directory: string; readonly workspaceID?: string }
       readonly location?: { readonly directory: string; readonly workspaceID?: string }
       readonly data: { readonly projectID: string }
       readonly data: { readonly projectID: string }
     }
     }
+  | {
+      readonly id: string
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "skill.updated"
+      readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: {}
+    }
   | {
   | {
       readonly id: string
       readonly id: string
       readonly metadata?: { readonly [x: string]: unknown }
       readonly metadata?: { readonly [x: string]: unknown }

+ 15 - 7
packages/core/src/filesystem/watcher.ts

@@ -6,6 +6,7 @@ import type ParcelWatcher from "@parcel/watcher"
 import { makeLocationNode } from "../effect/app-node"
 import { makeLocationNode } from "../effect/app-node"
 import { Cause, Context, Effect, Layer } from "effect"
 import { Cause, Context, Effect, Layer } from "effect"
 import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
 import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
+import os from "os"
 import path from "path"
 import path from "path"
 import { Config } from "../config"
 import { Config } from "../config"
 import { EventV2 } from "../event"
 import { EventV2 } from "../event"
@@ -57,10 +58,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 export const layer = Layer.effect(
 export const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
-    if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
+    if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
 
 
     const backend = getBackend()
     const backend = getBackend()
     const location = yield* Location.Service
     const location = yield* Location.Service
+    if (path.resolve(location.directory) === path.resolve(os.homedir())) {
+      yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory })
+      return Service.of({})
+    }
     if (!backend) {
     if (!backend) {
       yield* Effect.logError("watcher backend not supported", {
       yield* Effect.logError("watcher backend not supported", {
         directory: location.directory,
         directory: location.directory,
@@ -84,6 +89,7 @@ export const layer = Layer.effect(
     )
     )
 
 
     const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
     const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
+      if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
       for (const update of updates) {
       for (const update of updates) {
         if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
         if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
         if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
         if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
@@ -94,7 +100,11 @@ export const layer = Layer.effect(
     const subscribe = (directory: string, ignore: string[]) => {
     const subscribe = (directory: string, ignore: string[]) => {
       const pending = w.subscribe(directory, callback, { ignore, backend })
       const pending = w.subscribe(directory, callback, { ignore, backend })
       return Effect.promise(() => pending).pipe(
       return Effect.promise(() => pending).pipe(
-        Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
+        Effect.tap((subscription) =>
+          Effect.sync(() => subscriptions.push(subscription)).pipe(
+            Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
+          ),
+        ),
         Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
         Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
         Effect.catchCause((cause) => {
         Effect.catchCause((cause) => {
           pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
           pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
@@ -106,11 +116,9 @@ export const layer = Layer.effect(
     const config = (yield* (yield* Config.Service).entries())
     const config = (yield* (yield* Config.Service).entries())
       .filter((entry): entry is Config.Document => entry.type === "document")
       .filter((entry): entry is Config.Document => entry.type === "document")
       .flatMap((item) => item.info.watcher?.ignore ?? [])
       .flatMap((item) => item.info.watcher?.ignore ?? [])
-    if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-      yield* Effect.forkScoped(
-        subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
-      )
-    }
+    yield* Effect.forkScoped(
+      subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
+    )
 
 
     if (location.vcs?.type === "git") {
     if (location.vcs?.type === "git") {
       const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
       const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory

+ 1 - 6
packages/core/src/flag/flag.ts

@@ -32,14 +32,9 @@ export const Flag = {
   OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
   OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
   OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
   OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
   OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
   OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
+  OPENCODE_DISABLE_FILEWATCHER: truthy("OPENCODE_DISABLE_FILEWATCHER"),
 
 
   // Experimental
   // Experimental
-  OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
-    Config.withDefault(false),
-  ),
-  OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
-    Config.withDefault(false),
-  ),
   OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
   OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
     copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
     copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
   OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
   OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],

+ 26 - 1
packages/core/src/session.ts

@@ -39,6 +39,7 @@ import { Revert } from "@opencode-ai/schema/revert"
 import { FSUtil } from "./fs-util"
 import { FSUtil } from "./fs-util"
 import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
 import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
 import { SkillV2 } from "./skill"
 import { SkillV2 } from "./skill"
+import { Job } from "./job"
 
 
 export const RevertState = Revert.State
 export const RevertState = Revert.State
 export type RevertState = Revert.State
 export type RevertState = Revert.State
@@ -191,9 +192,14 @@ export interface Interface {
   ) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
   ) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
   readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
   readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
+  readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
   readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
   readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
   readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
-  readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect<void, NotFoundError>
+  readonly synthetic: (input: {
+    sessionID: SessionSchema.ID
+    text: string
+    description?: string
+  }) => Effect.Effect<void, NotFoundError>
   readonly revert: {
   readonly revert: {
     readonly stage: (input: {
     readonly stage: (input: {
       sessionID: SessionSchema.ID
       sessionID: SessionSchema.ID
@@ -217,6 +223,7 @@ export const layer = Layer.effect(
     const execution = yield* SessionExecution.Service
     const execution = yield* SessionExecution.Service
     const store = yield* SessionStore.Service
     const store = yield* SessionStore.Service
     const locations = yield* LocationServiceMap.Service
     const locations = yield* LocationServiceMap.Service
+    const jobs = yield* Job.Service
     const scope = yield* Scope.Scope
     const scope = yield* Scope.Scope
     const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
     const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
     const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
     const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -512,6 +519,22 @@ export const layer = Layer.effect(
         yield* execution.awaitIdle(sessionID)
         yield* execution.awaitIdle(sessionID)
       }),
       }),
       active: execution.active,
       active: execution.active,
+      background: Effect.fn("V2Session.background")(function* (sessionID) {
+        yield* result.get(sessionID)
+        const backgrounded = yield* jobs.backgroundAll({ sessionID })
+        if (backgrounded.length === 0) return
+        yield* result.synthetic({
+          sessionID,
+          text: [
+            "User requested that active blocking work be moved to the background.",
+            "",
+            "Backgrounded work:",
+            ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`),
+            "",
+            "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.",
+          ].join("\n"),
+        })
+      }),
       resume: Effect.fn("V2Session.resume")(function* (sessionID) {
       resume: Effect.fn("V2Session.resume")(function* (sessionID) {
         yield* result.get(sessionID)
         yield* result.get(sessionID)
         yield* execution.resume(sessionID)
         yield* execution.resume(sessionID)
@@ -523,6 +546,7 @@ export const layer = Layer.effect(
           messageID: SessionMessage.ID.create(),
           messageID: SessionMessage.ID.create(),
           timestamp: yield* DateTime.now,
           timestamp: yield* DateTime.now,
           text: input.text,
           text: input.text,
+          description: input.description,
         })
         })
         yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
         yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
       }),
       }),
@@ -587,6 +611,7 @@ export const node = makeGlobalNode({
   service: Service,
   service: Service,
   layer: layer.pipe(Layer.orDie),
   layer: layer.pipe(Layer.orDie),
   deps: [
   deps: [
+    Job.node,
     Database.node,
     Database.node,
     EventV2.node,
     EventV2.node,
     ProjectV2.node,
     ProjectV2.node,

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

@@ -153,6 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
           SessionMessage.Synthetic.make({
           SessionMessage.Synthetic.make({
             sessionID: event.data.sessionID,
             sessionID: event.data.sessionID,
             text: event.data.text,
             text: event.data.text,
+            description: event.data.description,
             id: event.data.messageID,
             id: event.data.messageID,
             type: "synthetic",
             type: "synthetic",
             time: { created: event.data.timestamp },
             time: { created: event.data.timestamp },

+ 44 - 8
packages/core/src/skill.ts

@@ -2,10 +2,12 @@ export * as SkillV2 from "./skill"
 
 
 import { makeLocationNode } from "./effect/app-node"
 import { makeLocationNode } from "./effect/app-node"
 import path from "path"
 import path from "path"
-import { Context, Effect, Layer, Schema, Types } from "effect"
+import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
+import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
 import { Skill } from "@opencode-ai/schema/skill"
 import { Skill } from "@opencode-ai/schema/skill"
 import { AgentV2 } from "./agent"
 import { AgentV2 } from "./agent"
 import { ConfigMarkdown } from "./config/markdown"
 import { ConfigMarkdown } from "./config/markdown"
+import { EventV2 } from "./event"
 import { FSUtil } from "./fs-util"
 import { FSUtil } from "./fs-util"
 import { PermissionV2 } from "./permission"
 import { PermissionV2 } from "./permission"
 import { AbsolutePath } from "./schema"
 import { AbsolutePath } from "./schema"
@@ -27,6 +29,8 @@ export type Source = typeof Source.Type
 export const Info = Skill.Info
 export const Info = Skill.Info
 export type Info = Skill.Info
 export type Info = Skill.Info
 
 
+export const Event = Skill.Event
+
 export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
 export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
   skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
   skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
 
 
@@ -58,6 +62,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const discovery = yield* SkillDiscovery.Service
     const discovery = yield* SkillDiscovery.Service
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
+    const events = yield* EventV2.Service
 
 
     const state = State.create<Data, Draft>({
     const state = State.create<Data, Draft>({
       initial: () => ({ sources: [] }),
       initial: () => ({ sources: [] }),
@@ -72,7 +77,15 @@ export const layer = Layer.effect(
 
 
     const load = Effect.fn("SkillV2.load")(function* (source: Source) {
     const load = Effect.fn("SkillV2.load")(function* (source: Source) {
       const skills: Info[] = []
       const skills: Info[] = []
-      if (source.type === "embedded") return [source.skill]
+      if (source.type === "embedded") {
+        yield* Effect.logDebug("skill source loaded", {
+          source: Source.key(source),
+          type: source.type,
+          directories: [],
+          skills: [source.skill.name],
+        })
+        return { skills: [source.skill], directories: [] }
+      }
       const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
       const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
       for (const directory of directories) {
       for (const directory of directories) {
         const files = yield* fs
         const files = yield* fs
@@ -101,19 +114,42 @@ export const layer = Layer.effect(
           })
           })
         }
         }
       }
       }
-      return skills
+      yield* Effect.logDebug("skill source loaded", {
+        source: Source.key(source),
+        type: source.type,
+        directories,
+        skills: skills.map((skill) => skill.name),
+      })
+      return { skills, directories }
     })
     })
 
 
-    // QUESTION(Dax): Should local skill sources invalidate on filesystem watch
-    // events, following the reload policy chosen for other context sources?
-    const cache = new Map<string, Info[]>()
+    const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
+    const invalidate = Effect.fn("SkillV2.invalidateFromWatcher")(function* (file: string) {
+      const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
+        loaded.directories.some((directory) => FSUtil.contains(directory, file)),
+      )
+      if (invalidated.length === 0) return
+      for (const [key] of invalidated) cache.delete(key)
+      yield* Effect.logInfo("skill cache invalidated", {
+        file,
+        sources: invalidated.map(([key]) => key),
+        skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)),
+      })
+      yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
+    })
+
+    yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe(
+      Stream.runForEach((event) => invalidate(event.data.file)),
+      Effect.forkScoped({ startImmediately: true }),
+    )
+
     const list = Effect.fn("SkillV2.list")(function* () {
     const list = Effect.fn("SkillV2.list")(function* () {
       const skills = new Map<string, Info>()
       const skills = new Map<string, Info>()
       for (const source of state.get().sources) {
       for (const source of state.get().sources) {
         const key = Source.key(source)
         const key = Source.key(source)
         const loaded = cache.get(key) ?? (yield* load(source))
         const loaded = cache.get(key) ?? (yield* load(source))
         cache.set(key, loaded)
         cache.set(key, loaded)
-        for (const skill of loaded) skills.set(skill.name, skill)
+        for (const skill of loaded.skills) skills.set(skill.name, skill)
       }
       }
       return Array.from(skills.values())
       return Array.from(skills.values())
     })
     })
@@ -131,4 +167,4 @@ export const layer = Layer.effect(
 
 
 export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
 export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
 
 
-export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] })
+export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] })

+ 1 - 9
packages/core/test/filesystem/watcher.test.ts

@@ -2,7 +2,7 @@ import { $ } from "bun"
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import fs from "fs/promises"
 import path from "path"
 import path from "path"
-import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
+import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
 import { Config } from "@opencode-ai/core/config"
 import { Config } from "@opencode-ai/core/config"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -27,13 +27,6 @@ const configLayer = Layer.succeed(
   }),
   }),
 )
 )
 
 
-const flagsLayer = ConfigProvider.layer(
-  ConfigProvider.fromUnknown({
-    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
-    OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
-  }),
-)
-
 function provide(directory: string, vcs?: Location.Interface["vcs"]) {
 function provide(directory: string, vcs?: Location.Interface["vcs"]) {
   const locationLayer = Layer.succeed(
   const locationLayer = Layer.succeed(
     Location.Service,
     Location.Service,
@@ -44,7 +37,6 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
       Layer.provide(configLayer),
       Layer.provide(configLayer),
       Layer.provide(Git.defaultLayer),
       Layer.provide(Git.defaultLayer),
       Layer.provide(locationLayer),
       Layer.provide(locationLayer),
-      Layer.provide(flagsLayer),
     ),
     ),
   )
   )
 }
 }

+ 2 - 0
packages/core/test/move-session.test.ts

@@ -9,6 +9,7 @@ import { Database } from "@opencode-ai/core/database/database"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Git } from "@opencode-ai/core/git"
 import { Git } from "@opencode-ai/core/git"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { ProjectDirectories } from "@opencode-ai/core/project/directories"
@@ -29,6 +30,7 @@ const project = Project.layer.pipe(
   Layer.provide(ProjectDirectories.defaultLayer),
   Layer.provide(ProjectDirectories.defaultLayer),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),

+ 2 - 0
packages/core/test/permission.test.ts

@@ -3,6 +3,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionTable } from "@opencode-ai/core/permission/sql"
 import { PermissionTable } from "@opencode-ai/core/permission/sql"
@@ -24,6 +25,7 @@ const current = Layer.succeed(
   Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
   Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 2 - 0
packages/core/test/session-compact.test.ts

@@ -4,6 +4,7 @@ import { OpenAIChat } from "@opencode-ai/llm/protocols"
 import { Config } from "@opencode-ai/core/config"
 import { Config } from "@opencode-ai/core/config"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import type { LocationServices } from "@opencode-ai/core/location-services"
 import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -60,6 +61,7 @@ const locations = Layer.effect(
   ),
   ),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locations),
   Layer.provide(locations),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 5 - 5
packages/core/test/session-create.test.ts

@@ -6,6 +6,7 @@ import { asc, eq } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
+import { Job } from "@opencode-ai/core/job"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectV2 } from "@opencode-ai/core/project"
@@ -36,6 +37,7 @@ const projects = Layer.succeed(
   }),
   }),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),
@@ -182,11 +184,9 @@ describe("SessionV2.create", () => {
       expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
       expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
       expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
       expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
       expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
       expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
-      expect((yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq)).toEqual([
-        0,
-        4,
-        5,
-      ])
+      expect(
+        (yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq),
+      ).toEqual([0, 4, 5])
       expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
       expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
     }),
     }),
   )
   )

+ 2 - 0
packages/core/test/session-history.test.ts

@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectV2 } from "@opencode-ai/core/project"
@@ -23,6 +24,7 @@ const projects = Layer.succeed(
   }),
   }),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 7 - 1
packages/core/test/session-prompt.test.ts

@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
+import { Job } from "@opencode-ai/core/job"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -45,6 +46,7 @@ const execution = Layer.succeed(
   }),
   }),
 )
 )
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),
@@ -114,7 +116,11 @@ const eventCount = (type: string) =>
 
 
 const encodeMessage = Schema.encodeSync(SessionMessage.Message)
 const encodeMessage = Schema.encodeSync(SessionMessage.Message)
 const assistantRow = (id: SessionMessage.ID, seq: number) => {
 const assistantRow = (id: SessionMessage.ID, seq: number) => {
-  const { id: _, type, ...data } = encodeMessage(
+  const {
+    id: _,
+    type,
+    ...data
+  } = encodeMessage(
     SessionMessage.Assistant.make({
     SessionMessage.Assistant.make({
       id,
       id,
       type: "assistant",
       type: "assistant",

+ 2 - 0
packages/core/test/session-runner-recorded.test.ts

@@ -5,6 +5,7 @@ import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
+import { Job } from "@opencode-ai/core/job"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Config } from "@opencode-ai/core/config"
 import { Config } from "@opencode-ai/core/config"
@@ -111,6 +112,7 @@ const execution = Layer.effect(
   }),
   }),
 ).pipe(Layer.provide(runner))
 ).pipe(Layer.provide(runner))
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 2 - 0
packages/core/test/session-runner.test.ts

@@ -12,6 +12,7 @@ import {
 import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
 import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
@@ -266,6 +267,7 @@ const execution = Layer.effect(
   }),
   }),
 ).pipe(Layer.provide(runner))
 ).pipe(Layer.provide(runner))
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 2 - 0
packages/core/test/session-wait.test.ts

@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { Effect, Layer } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Job } from "@opencode-ai/core/job"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,6 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, {
   awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
   awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
 })
 })
 const sessions = SessionV2.layer.pipe(
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(Job.layer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(locationServiceMapLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(Database.defaultLayer),

+ 54 - 2
packages/core/test/skill.test.ts

@@ -1,14 +1,16 @@
 import fs from "fs/promises"
 import fs from "fs/promises"
 import path from "path"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { EventV2 } from "@opencode-ai/core/event"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
 import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
+import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
 
 
@@ -24,7 +26,7 @@ const discovery = Layer.succeed(
   }),
   }),
 )
 )
 const it = testEffect(
 const it = testEffect(
-  AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [
+  AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [
     LayerNode.replace(SkillDiscovery.layer, discovery),
     LayerNode.replace(SkillDiscovery.layer, discovery),
   ]),
   ]),
 )
 )
@@ -40,6 +42,19 @@ description: ${description}
   )
   )
 }
 }
 
 
+function waitForSkillUpdate() {
+  return Effect.gen(function* () {
+    const events = yield* EventV2.Service
+    const deferred = yield* Deferred.make<void>()
+    const fiber = yield* events.subscribe(SkillV2.Event.Updated).pipe(
+      Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
+      Effect.forkScoped,
+    )
+    yield* Effect.yieldNow
+    return { deferred, fiber }
+  })
+}
+
 describe("SkillV2", () => {
 describe("SkillV2", () => {
   it.live("registers sources and resolves later source precedence", () =>
   it.live("registers sources and resolves later source precedence", () =>
     Effect.acquireRelease(
     Effect.acquireRelease(
@@ -124,4 +139,41 @@ describe("SkillV2", () => {
       ),
       ),
     ),
     ),
   )
   )
+
+  it.live("invalidates cached skills and publishes updates for watcher changes", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* Effect.promise(async () => {
+            await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
+            await write(tmp.path, "deploy", "Initial deploy")
+          })
+
+          const events = yield* EventV2.Service
+          const skill = yield* SkillV2.Service
+          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
+
+          expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
+
+          const file = path.join(tmp.path, "deploy", "SKILL.md")
+          yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
+          expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
+
+          yield* Effect.acquireUseRelease(
+            waitForSkillUpdate(),
+            ({ deferred }) =>
+              events
+                .publish(FileSystemWatcher.Event.Updated, { file, event: "change" })
+                .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
+            ({ fiber }) => Fiber.interrupt(fiber),
+          )
+
+          expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
+        }),
+      ),
+    ),
+  )
 })
 })

+ 0 - 1
packages/desktop/src/main/server.ts

@@ -46,7 +46,6 @@ export function preferAppEnv(userDataPath: string) {
   Object.assign(process.env, {
   Object.assign(process.env, {
     ...(shell ? loadShellEnv(shell, getLogger()) : null),
     ...(shell ? loadShellEnv(shell, getLogger()) : null),
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
-    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
     OPENCODE_CLIENT: "desktop",
     OPENCODE_CLIENT: "desktop",
     XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
     XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
   })
   })

+ 1 - 1
packages/desktop/src/main/wsl/sidecar.ts

@@ -29,7 +29,7 @@ export async function spawnWslSidecar(
     'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
     'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
     "export PATH",
     "export PATH",
     "export WSLENV=",
     "export WSLENV=",
-    "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
+    "export OPENCODE_DISABLE_FILEWATCHER=true",
     "export OPENCODE_CLIENT=desktop",
     "export OPENCODE_CLIENT=desktop",
     `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
     `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
     `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
     `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,

+ 2 - 0
packages/schema/src/event-manifest.ts

@@ -23,6 +23,7 @@ import { QuestionV1 } from "./question-v1.js"
 import { Reference } from "./reference.js"
 import { Reference } from "./reference.js"
 import { ServerEvent } from "./server-event.js"
 import { ServerEvent } from "./server-event.js"
 import { Shell } from "./shell.js"
 import { Shell } from "./shell.js"
+import { Skill } from "./skill.js"
 import { SessionCompactionEvent } from "./session-compaction-event.js"
 import { SessionCompactionEvent } from "./session-compaction-event.js"
 import { SessionEvent } from "./session-event.js"
 import { SessionEvent } from "./session-event.js"
 import { SessionStatusEvent } from "./session-status-event.js"
 import { SessionStatusEvent } from "./session-status-event.js"
@@ -52,6 +53,7 @@ const featureDefinitions = Event.inventory(
   ...Permission.Event.Definitions,
   ...Permission.Event.Definitions,
   ...Plugin.Event.Definitions,
   ...Plugin.Event.Definitions,
   ...ProjectDirectories.Event.Definitions,
   ...ProjectDirectories.Event.Definitions,
+  ...Skill.Event.Definitions,
   ...FileSystemWatcher.Event.Definitions,
   ...FileSystemWatcher.Event.Definitions,
   ...Pty.Event.Definitions,
   ...Pty.Event.Definitions,
   ...Shell.Event.Definitions,
   ...Shell.Event.Definitions,

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

@@ -137,6 +137,7 @@ export const Synthetic = Event.define({
     ...Base,
     ...Base,
     messageID: SessionMessage.ID,
     messageID: SessionMessage.ID,
     text: Schema.String,
     text: Schema.String,
+    description: Schema.String.pipe(optional),
   },
   },
 })
 })
 export type Synthetic = typeof Synthetic.Type
 export type Synthetic = typeof Synthetic.Type

+ 1 - 0
packages/schema/src/session-message.ts

@@ -55,6 +55,7 @@ export const Synthetic = Schema.Struct({
   ...Base,
   ...Base,
   sessionID: SessionID,
   sessionID: SessionID,
   text: Schema.String,
   text: Schema.String,
+  description: Schema.String.pipe(optional),
   type: Schema.Literal("synthetic"),
   type: Schema.Literal("synthetic"),
 }).annotate({ identifier: "Session.Message.Synthetic" })
 }).annotate({ identifier: "Session.Message.Synthetic" })
 
 

+ 4 - 0
packages/schema/src/skill.ts

@@ -3,6 +3,7 @@ export * as Skill from "./skill.js"
 import { Schema } from "effect"
 import { Schema } from "effect"
 import { optional } from "./schema.js"
 import { optional } from "./schema.js"
 import { AbsolutePath } from "./schema.js"
 import { AbsolutePath } from "./schema.js"
+import { define, inventory } from "./event.js"
 
 
 export interface DirectorySource extends Schema.Schema.Type<typeof DirectorySource> {}
 export interface DirectorySource extends Schema.Schema.Type<typeof DirectorySource> {}
 export const DirectorySource = Schema.Struct({
 export const DirectorySource = Schema.Struct({
@@ -25,6 +26,9 @@ export const Info = Schema.Struct({
   content: Schema.String,
   content: Schema.String,
 }).annotate({ identifier: "SkillV2.Info" })
 }).annotate({ identifier: "SkillV2.Info" })
 
 
+const Updated = define({ type: "skill.updated", schema: {} })
+export const Event = { Updated, Definitions: inventory(Updated) }
+
 export interface EmbeddedSource extends Schema.Schema.Type<typeof EmbeddedSource> {}
 export interface EmbeddedSource extends Schema.Schema.Type<typeof EmbeddedSource> {}
 export const EmbeddedSource = Schema.Struct({
 export const EmbeddedSource = Schema.Struct({
   type: Schema.Literal("embedded"),
   type: Schema.Literal("embedded"),

+ 39 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -63,6 +63,7 @@ export type Event =
   | EventPermissionV2Replied
   | EventPermissionV2Replied
   | EventPluginAdded
   | EventPluginAdded
   | EventProjectDirectoriesUpdated
   | EventProjectDirectoriesUpdated
+  | EventSkillUpdated
   | EventFileWatcherUpdated
   | EventFileWatcherUpdated
   | EventPtyCreated
   | EventPtyCreated
   | EventPtyUpdated
   | EventPtyUpdated
@@ -940,6 +941,7 @@ export type GlobalEvent = {
           sessionID: string
           sessionID: string
           messageID: string
           messageID: string
           text: string
           text: string
+          description?: string
         }
         }
       }
       }
     | {
     | {
@@ -1354,6 +1356,13 @@ export type GlobalEvent = {
           projectID: string
           projectID: string
         }
         }
       }
       }
+    | {
+        id: string
+        type: "skill.updated"
+        properties: {
+          [key: string]: unknown
+        }
+      }
     | {
     | {
         id: string
         id: string
         type: "file.watcher.updated"
         type: "file.watcher.updated"
@@ -3037,6 +3046,7 @@ export type V2Event =
   | PermissionV2Replied
   | PermissionV2Replied
   | PluginAdded
   | PluginAdded
   | ProjectDirectoriesUpdated
   | ProjectDirectoriesUpdated
+  | SkillUpdated
   | FileWatcherUpdated
   | FileWatcherUpdated
   | PtyCreated
   | PtyCreated
   | PtyUpdated
   | PtyUpdated
@@ -3607,6 +3617,7 @@ export type SyncEventSessionNextSynthetic = {
       sessionID: string
       sessionID: string
       messageID: string
       messageID: string
       text: string
       text: string
+      description?: string
     }
     }
   }
   }
 }
 }
@@ -4210,6 +4221,7 @@ export type SessionMessageSynthetic = {
   }
   }
   sessionID: string
   sessionID: string
   text: string
   text: string
+  description?: string
   type: "synthetic"
   type: "synthetic"
 }
 }
 
 
@@ -4570,6 +4582,7 @@ export type SessionNextSynthetic = {
     sessionID: string
     sessionID: string
     messageID: string
     messageID: string
     text: string
     text: string
+    description?: string
   }
   }
 }
 }
 
 
@@ -5861,6 +5874,23 @@ export type ProjectDirectoriesUpdated = {
   }
   }
 }
 }
 
 
+export type SkillUpdated = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  type: "skill.updated"
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  data: {
+    [key: string]: unknown
+  }
+}
+
 export type FileWatcherUpdated = {
 export type FileWatcherUpdated = {
   id: string
   id: string
   metadata?: {
   metadata?: {
@@ -6774,6 +6804,7 @@ export type EventSessionNextSynthetic = {
     sessionID: string
     sessionID: string
     messageID: string
     messageID: string
     text: string
     text: string
+    description?: string
   }
   }
 }
 }
 
 
@@ -7226,6 +7257,14 @@ export type EventProjectDirectoriesUpdated = {
   }
   }
 }
 }
 
 
+export type EventSkillUpdated = {
+  id: string
+  type: "skill.updated"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
 export type EventFileWatcherUpdated = {
 export type EventFileWatcherUpdated = {
   id: string
   id: string
   type: "file.watcher.updated"
   type: "file.watcher.updated"

+ 1 - 29
packages/server/src/handlers/session.ts

@@ -3,7 +3,6 @@ import { DateTime, Effect, Stream } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
 import { Api } from "../api"
 import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
 import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
-import { Job } from "@opencode-ai/core/job"
 import {
 import {
   ConflictError,
   ConflictError,
   InvalidCursorError,
   InvalidCursorError,
@@ -22,7 +21,6 @@ const DefaultSessionHistoryLimit = 50
 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const session = yield* SessionV2.Service
     const session = yield* SessionV2.Service
-    const jobs = yield* Job.Service
 
 
     return handlers
     return handlers
       .handle(
       .handle(
@@ -486,7 +484,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
       .handle(
       .handle(
         "session.background",
         "session.background",
         Effect.fn(function* (ctx) {
         Effect.fn(function* (ctx) {
-          yield* session.get(ctx.params.sessionID).pipe(
+          yield* session.background(ctx.params.sessionID).pipe(
             Effect.catchTag("Session.NotFoundError", (error) =>
             Effect.catchTag("Session.NotFoundError", (error) =>
               Effect.fail(
               Effect.fail(
                 new SessionNotFoundError({
                 new SessionNotFoundError({
@@ -496,32 +494,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
               ),
               ),
             ),
             ),
           )
           )
-          const backgrounded = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID })
-          if (backgrounded.length > 0)
-            yield* session
-              .synthetic({
-                sessionID: ctx.params.sessionID,
-                text: [
-                  "User requested that active blocking work be moved to the background.",
-                  "",
-                  "Backgrounded work:",
-                  ...backgrounded.map(
-                    (job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`,
-                  ),
-                  "",
-                  "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.",
-                ].join("\n"),
-              })
-              .pipe(
-                Effect.catchTag("Session.NotFoundError", (error) =>
-                  Effect.fail(
-                    new SessionNotFoundError({
-                      sessionID: error.sessionID,
-                      message: `Session not found: ${error.sessionID}`,
-                    }),
-                  ),
-                ),
-              )
           return HttpApiSchema.NoContent.make()
           return HttpApiSchema.NoContent.make()
         }),
         }),
       )
       )

+ 4 - 0
packages/tui/src/context/data.tsx

@@ -163,6 +163,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         case "agent.updated":
         case "agent.updated":
           void result.location.agent.refresh(event.location)
           void result.location.agent.refresh(event.location)
           break
           break
+        case "skill.updated":
+          void result.location.skill.refresh(event.location)
+          break
         case "session.next.agent.switched":
         case "session.next.agent.switched":
           if (store.session.info[event.data.sessionID])
           if (store.session.info[event.data.sessionID])
             setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
             setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
@@ -248,6 +251,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
               type: "synthetic",
               type: "synthetic",
               sessionID: event.data.sessionID,
               sessionID: event.data.sessionID,
               text: event.data.text,
               text: event.data.text,
+              description: event.data.description,
               time: { created: event.data.timestamp },
               time: { created: event.data.timestamp },
             })
             })
           })
           })

+ 5 - 4
packages/tui/src/routes/session/index.tsx

@@ -1250,13 +1250,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
 function SessionNoticeMessageV2(props: { message: SessionMessage }) {
 function SessionNoticeMessageV2(props: { message: SessionMessage }) {
   const { theme } = useTheme()
   const { theme } = useTheme()
   const text = () => {
   const text = () => {
-    if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text
+    if (props.message.type === "system") return "Instructions updated"
+    if (props.message.type === "synthetic") return props.message.description ?? ""
     return ""
     return ""
   }
   }
   return (
   return (
-    <box paddingLeft={3}>
-      <text fg={theme.textMuted}>{text()}</text>
-    </box>
+    <InlineToolRow icon="◈" color={theme.textMuted} pending="Notice" complete={true}>
+      {text()}
+    </InlineToolRow>
   )
   )
 }
 }
 
 

+ 4 - 1
packages/tui/src/routes/session/rows.ts

@@ -119,7 +119,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
     data.on("session.next.prompt.admitted", message),
     data.on("session.next.prompt.admitted", message),
     data.on("session.next.prompted", message),
     data.on("session.next.prompted", message),
     data.on("session.next.context.updated", message),
     data.on("session.next.context.updated", message),
-    data.on("session.next.synthetic", message),
+    data.on("session.next.synthetic", (event) => {
+      if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID)
+    }),
     data.on("session.next.shell.started", message),
     data.on("session.next.shell.started", message),
     data.on("session.next.agent.switched", message),
     data.on("session.next.agent.switched", message),
     data.on("session.next.model.switched", message),
     data.on("session.next.model.switched", message),
@@ -160,6 +162,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
 export function reduceSessionRows(messages: SessionMessage[]) {
 export function reduceSessionRows(messages: SessionMessage[]) {
   return messages.reduce<SessionRow[]>((rows, message) => {
   return messages.reduce<SessionRow[]>((rows, message) => {
     if (message.type !== "assistant") {
     if (message.type !== "assistant") {
+      if (message.type === "synthetic" && !message.description?.trim()) return rows
       completePrevious(rows)
       completePrevious(rows)
       rows.push({ type: "message", messageID: message.id })
       rows.push({ type: "message", messageID: message.id })
       return rows
       return rows

+ 60 - 0
packages/tui/test/cli/tui/session-rows.test.ts

@@ -122,6 +122,66 @@ test("completes exploration groups when another row follows", () => {
   ])
   ])
 })
 })
 
 
+test("hides synthetic messages without descriptions", () => {
+  const messages: SessionMessage[] = [
+    assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
+    {
+      type: "synthetic",
+      id: "synthetic-1",
+      sessionID: "session-1",
+      text: "internal context",
+      time: { created: 2 },
+    },
+    assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
+  ]
+
+  expect(reduceSessionRows(messages)).toEqual([
+    {
+      type: "group",
+      kind: "exploration",
+      pending: [],
+      completed: false,
+      refs: [
+        { messageID: "assistant-1", partID: "read-1" },
+        { messageID: "assistant-2", partID: "grep-1" },
+      ],
+    },
+  ])
+})
+
+test("renders synthetic messages with descriptions", () => {
+  const messages: SessionMessage[] = [
+    assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
+    {
+      type: "synthetic",
+      id: "synthetic-1",
+      sessionID: "session-1",
+      text: "internal context",
+      description: "Explicit notice",
+      time: { created: 2 },
+    },
+    assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
+  ]
+
+  expect(reduceSessionRows(messages)).toEqual([
+    {
+      type: "group",
+      kind: "exploration",
+      pending: [],
+      completed: true,
+      refs: [{ messageID: "assistant-1", partID: "read-1" }],
+    },
+    { type: "message", messageID: "synthetic-1" },
+    {
+      type: "group",
+      kind: "exploration",
+      pending: [],
+      completed: false,
+      refs: [{ messageID: "assistant-2", partID: "grep-1" }],
+    },
+  ])
+})
+
 function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
 function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
   return {
   return {
     type: "assistant",
     type: "assistant",