Jelajahi Sumber

Add skill discovery and loading

Yichen Jiang 3 bulan lalu
induk
melakukan
45be662e85

+ 4 - 1
docs/architecture.md

@@ -24,10 +24,12 @@ For a catalog of the **data structures** this architecture moves around — the
 │  @deepseek-ai/dsh-agent-loop      (the ONE concrete plugin)  │
 │  @deepseek-ai/dsh-bash-local      (bash impl)                │
 │  @deepseek-ai/dsh-tool-bash       (bash tool schemas)        │
+│  @deepseek-ai/dsh-tool-skill      (skill loader tool)        │
 │  @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│
 ├─────────────────────────────────────────────────────────────┤
 │  @deepseek-ai/dsh-agent           (vocabulary + registry)    │
 │  @deepseek-ai/dsh-tools           (registry + exec waterfall)│
+│  @deepseek-ai/dsh-skill           (skill discovery + listing)│
 │  @deepseek-ai/dsh-system-prompt   (assembly registry)        │
 │  @deepseek-ai/dsh-session         (event-sourced log)        │
 │  @deepseek-ai/dsh-session-persistence (persistence seam)     │
@@ -50,6 +52,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d
 | `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions |
 | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` |
 | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall |
+| `ctx.skills` | `SkillService` | dsh-skill | discovers user/project/system skills and adds request-time skill guidance |
 | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) |
 | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops |
 | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
@@ -202,7 +205,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
 | Plan mode | wrap `tools/execute` (deny writes) + `agent/request` (inject mode prompt) |
 | Sub-agents (spawn / fork / steer) | TODO seam on `AgentLoop.create()`; fork = seed Session with parent events; `steer()` on the child handle |
 | MCP | one plugin per server: discover tools → `ctx.tools.register()` |
-| Skills | section + tool registration; `inject()` skill content on invocation |
+| Skills | `dsh-skill` discovers `~/.dsh/skills`, `~/.agents/skills`, project `.dsh/skills`/`.agents/skills`, and system `~/.dsh/skills/.system`; `agent/request` appends the model-visible listing; `dsh-tool-skill` loads full skill content on demand |
 | Memory | section provider + tool |
 | Scheduled tasks (cron) | plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
 | UI (GUI; CLI emits JSONL) | listen `agent/stream-chunk` + `session/event`; input → `send()` |

+ 14 - 3
docs/cordis-catalog/events-and-services.md

@@ -301,7 +301,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in
 
 ## Services
 
-The 9 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
+The 10 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
 
 ### `ctx.agentLoop` — `AgentLoop`
 
@@ -310,12 +310,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo
 The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent.
 
 ```ts cordis-catalog
-create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent
+create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
 createAgent(options: CreateAgentOptions): AgentHandle
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:65`](../../packages/core/agent-loop/src/index.ts)
 
 ### `ctx.agents` — `AgentRegistry`
 
@@ -414,6 +414,17 @@ list(): Session[]
 
 Source: [`packages/core/session/src/index.ts:229`](../../packages/core/session/src/index.ts)
 
+### `ctx.skills` — `SkillService`
+
+```ts cordis-catalog
+register(skill: SkillRegistration): () => void
+async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
+async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
+async renderModelListing(options: SkillLookupOptions = {}): Promise<string>
+```
+
+Source: [`packages/core/skill/src/index.ts:106`](../../packages/core/skill/src/index.ts)
+
 ### `ctx.subagents` — `SubagentService`
 
 The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.

+ 11 - 1
docs/module-graph.md

@@ -28,6 +28,8 @@ graph TD
   session-persistence-jsonl --> session-persistence
   session-persistence-sqlite --> session
   session-persistence-sqlite --> session-persistence
+  skill --> agent
+  skill --> llm
   tools --> agent
   tools --> llm
   tools --> system-prompt
@@ -52,13 +54,19 @@ graph TD
   tool-bash --> bash
   tool-bash --> llm
   tool-bash --> tools
+  tool-skill --> agent
+  tool-skill --> llm
+  tool-skill --> skill
+  tool-skill --> tools
   agent-core --> agent
   agent-core --> agent-loop
   agent-core --> invariants
   agent-core --> llm
   agent-core --> session
+  agent-core --> skill
   agent-core --> system-prompt
   agent-core --> tool-bash
+  agent-core --> tool-skill
   agent-core --> tools
   subagent-acp --> agent
   subagent-acp --> llm
@@ -106,13 +114,15 @@ graph TD
 | `invariants` | `agent`, `llm`, `session` |
 | `session-persistence-jsonl` | `session`, `session-persistence` |
 | `session-persistence-sqlite` | `session`, `session-persistence` |
+| `skill` | `agent`, `llm` |
 | `tools` | `agent`, `llm`, `system-prompt` |
 | `ui-stdio` | `agent`, `llm`, `session` |
 | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
 | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
 | `subagent` | `agent`, `llm`, `tools` |
 | `tool-bash` | `agent`, `bash`, `llm`, `tools` |
-| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
+| `tool-skill` | `agent`, `llm`, `skill`, `tools` |
+| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `skill`, `system-prompt`, `tool-bash`, `tool-skill`, `tools` |
 | `subagent-acp` | `agent`, `llm`, `subagent` |
 | `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` |
 | `subagent-mock` | `agent`, `llm`, `subagent` |

+ 5 - 1
packages/README.md

@@ -29,6 +29,8 @@ dsh-session       ← dsh-llm, dsh-brand
 dsh-system-prompt ← dsh-llm
 dsh-agent         ← dsh-llm, dsh-session, dsh-brand
 dsh-tools         ← dsh-llm, dsh-system-prompt, dsh-agent
+dsh-skill         ← dsh-llm, dsh-agent
+dsh-tool-skill    ← dsh-skill, dsh-tools, dsh-agent, dsh-llm
 dsh-bash-local    ← dsh-bash                       (BashExecutor impl)
 dsh-tool-bash     ← dsh-bash, dsh-tools            (bash tool schemas)
 dsh-llm-deepseek  ← dsh-llm                        (DeepSeek adapter)
@@ -44,7 +46,7 @@ dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm  (in-proces
 dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session      (in-process child seeded from parent log)
 dsh-subagent-acp  ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk  (out-of-process child over ACP)
 dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool)
-dsh-agent-core    ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop  (the providerless spine, as one bundle plugin)
+dsh-agent-core    ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-skill, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-tool-skill, dsh-agent-loop  (the providerless spine, as one bundle plugin)
 dsh-stdio-agent   ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session  (stdio chat APP + bin)
 dsh-acp-agent     ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl     (ACP server APP + bin)
 ```
@@ -59,6 +61,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
 | `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` |
 | `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
 | `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
+| `skill/` | `core` | Skill discovery + request-time model listing | `ctx.skills` |
+| `tool-skill/` | `core` | Model-facing `skill` loader tool | (registers on `ctx.tools`) |
 | `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
 | `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
 | `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |

+ 3 - 1
packages/core/README.md

@@ -7,10 +7,12 @@ The packages every harness build is assembled from: the session log, the system-
 | `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
 | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
 | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
+| `skill/` | Agent skill discovery + request-time skill listing | `ctx.skills` |
+| `tool-skill/` | Model-facing `skill` loader tool | (registers on `ctx.tools`) |
 | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
 | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
 | `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
 
 `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
 
-`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.
+`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.

+ 4 - 0
packages/core/agent-core/package.json

@@ -28,8 +28,10 @@
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-skill": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
     "@deepseek-ai/dsh-tool-bash": "^0.0.1",
+    "@deepseek-ai/dsh-tool-skill": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
@@ -40,8 +42,10 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }

+ 6 - 2
packages/core/agent-core/src/index.ts

@@ -3,8 +3,8 @@
  *
  * Loads the fixed set of services every harness agent needs — `timer`, the LLM
  * service, the session store, system-prompt assembly, the tool registry, the
- * agent registry, the dev-mode invariants, the model-facing `bash` tool
- * schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
+ * skill registry, the agent registry, the dev-mode invariants, the model-facing
+ * `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
  * list as its OWN config (default `[]`), so each app supplies its own
  * pre-created agents.
  *
@@ -48,9 +48,11 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
+import SkillService from '@deepseek-ai/dsh-skill'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import * as invariants from '@deepseek-ai/dsh-invariants'
 import * as toolBash from '@deepseek-ai/dsh-tool-bash'
+import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
 import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
 
 export const name = 'agent-core'
@@ -81,8 +83,10 @@ export function apply(ctx: Context, config: Config): void {
   ctx.plugin(SessionStore)
   ctx.plugin(SystemPrompt)
   ctx.plugin(ToolRegistry)
+  ctx.plugin(SkillService)
   ctx.plugin(AgentRegistry)
   ctx.plugin(invariants)
   ctx.plugin(toolBash)
+  ctx.plugin(toolSkill)
   ctx.plugin(AgentLoop, { agents: config.agents })
 }

+ 32 - 5
packages/core/agent-core/tests/agent-core.spec.ts

@@ -1,4 +1,7 @@
 import { describe, expect, it } from 'vitest'
+import { mkdtemp } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import * as agentCore from '../src/index.ts'
@@ -15,12 +18,22 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
  * bin smokes; here we assert the composition + config forwarding.
  */
 async function mount(config?: agentCore.Config): Promise<Context> {
+  const oldDshHome = process.env.DSH_HOME
+  process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
   const ctx = new Context()
-  await ctx.plugin(agentCore, config)
-  // The bundle mounts its children inside apply() (not awaited there); let their
-  // fibers settle so the spine services and any pre-created agent are ready.
-  await new Promise(resolve => setTimeout(resolve, 50))
-  return ctx
+  try {
+    await ctx.plugin(agentCore, config)
+    // The bundle mounts its children inside apply() (not awaited there); let their
+    // fibers settle so the spine services and any pre-created agent are ready.
+    await new Promise(resolve => setTimeout(resolve, 50))
+    return ctx
+  } finally {
+    if (oldDshHome === undefined) {
+      delete process.env.DSH_HOME
+    } else {
+      process.env.DSH_HOME = oldDshHome
+    }
+  }
 }
 
 describe('dsh-agent-core bundle', () => {
@@ -32,11 +45,25 @@ describe('dsh-agent-core bundle', () => {
     expect(ctx.get('sessions')).toBeDefined()
     expect(ctx.get('systemPrompt')).toBeDefined()
     expect(ctx.get('tools')).toBeDefined()
+    expect(ctx.get('skills')).toBeDefined()
     expect(ctx.get('agents')).toBeDefined()
     expect(ctx.get('agentLoop')).toBeDefined()
     await ctx.fiber.dispose()
   })
 
+  it('includes the default skill system and skill tool', async () => {
+    const ctx = await mount()
+
+    expect(ctx.skills).toBeDefined()
+    expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(expect.arrayContaining([
+      'dsh-plugin-creator',
+      'dsh-skill-creator',
+    ]))
+
+    await ctx.fiber.dispose()
+  })
+
   it('defaults the agents list to empty (no pre-created agents)', async () => {
     const ctx = await mount()
     expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()

+ 6 - 0
packages/core/agent-core/tsconfig.json

@@ -26,6 +26,12 @@
     {
       "path": "../../core/tools"
     },
+    {
+      "path": "../../core/skill"
+    },
+    {
+      "path": "../../core/tool-skill"
+    },
     {
       "path": "../../core/agent"
     },

+ 3 - 2
packages/core/agent-loop/README.md

@@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every
 
 ### Public API
 
-- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
+- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
 
 `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
 
@@ -29,11 +29,12 @@ interface Config {
     id: string                 // required
     model?: string
     systemPrompt?: string
+    cwd?: string               // optional workspace cwd for the fresh session
   }>
 }
 ```
 
-Agents listed in config are auto-created at startup.
+Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header.
 
 ### Classes
 

+ 10 - 7
packages/core/agent-loop/src/index.ts

@@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'
 import z from 'schemastery'
 import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-llm'
-import { SessionId } from '@deepseek-ai/dsh-session'
+import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tools'
@@ -33,6 +33,8 @@ export interface Config {
   /** Agents created from configuration at startup. */
   agents: (AgentOptions & {
     id: AgentId
+    /** Optional workspace cwd for the config-created fresh session. */
+    cwd?: string
     /**
      * If set, the config agent RESUMES this persisted session id instead of
      * starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -73,6 +75,7 @@ export class AgentLoop extends Service implements AgentFactory {
       id: z.string().required(),
       model: z.string(),
       systemPrompt: z.string(),
+      cwd: z.string(),
       resumeSessionId: z.string(),
     })).default([]),
   }) as unknown as z<Config>
@@ -82,7 +85,7 @@ export class AgentLoop extends Service implements AgentFactory {
     // Provide the agent-creation factory to the registry (effect-scoped: the
     // slot is cleared on dispose).
     ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
-    for (const { id, resumeSessionId, ...options } of config.agents) {
+    for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
       if (resumeSessionId !== undefined && resumeSessionId !== '') {
         // Resume a prior session instead of starting fresh. resume() needs
         // `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
@@ -101,15 +104,15 @@ export class AgentLoop extends Service implements AgentFactory {
           return () => void fiber.dispose()
         }, `agentLoop.resume(${id})`)
       } else {
-        this.create(id, options)
+        this.create(id, options, cwd === undefined ? {} : { cwd })
       }
     }
   }
 
   /**
    * Config-driven create: an agent on a FRESH, non-colliding session id per run
-   * (`${id}-session-<uuid>`, no cwd). Used for `cordis.yml`-configured agents
-   * and as the shared core for the programmatic factory {@link createAgent}.
+   * (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
+   * the shared core for the programmatic factory {@link createAgent}.
    *
    * Why a per-run id, not a fixed `${id}-session`: once a durable persistence
    * backend is loaded, a fixed id collides on the second run — the backend
@@ -122,13 +125,13 @@ export class AgentLoop extends Service implements AgentFactory {
    * else start fresh) or an explicit caller-chosen session id — revisit when the
    * UI/ACP path owns session selection.
    */
-  create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
+  create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
     this.assertAgentIdFree(id)
     // Config/programmatic path: prepare the session and let start() fold its
     // lifecycle into the agent's composite effect (so a fiber unload tears the
     // session + agent down as one ordered chain, capturing the loop's closing
     // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
-    const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
+    const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
     const { agent } = this.start(id, options, session)
     return agent
   }

+ 15 - 0
packages/core/agent-loop/tests/loop.spec.ts

@@ -677,6 +677,21 @@ describe('agent loop', () => {
     expect(adapter.requests).toHaveLength(1)
   })
 
+  it('attaches config agent cwd to the fresh session header', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(AgentLoop, {
+      agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: '', cwd: '/work/project' }],
+    })
+
+    const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
+    expect(agent.session.header.cwd).toBe('/work/project')
+  })
+
   it('replays a session log into an identical derived history', async () => {
     const adapter = new MockAdapter([
       toolCallResponse('c1', 'echo', { text: 'x' }),

+ 41 - 0
packages/core/skill/README.md

@@ -0,0 +1,41 @@
+# @deepseek-ai/dsh-skill
+
+Agent skill discovery and model-facing skill guidance.
+
+## Service: `SkillService` (ctx key: `skills`)
+
+### Public API
+
+- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace.
+- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
+- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber.
+
+### Discovery
+
+Default roots are resolved in this conflict priority order:
+
+| Source | Path |
+|---|---|
+| Project DSH | `<projectRoot>/.dsh/skills` |
+| Project agents | `<projectRoot>/.agents/skills` |
+| Runtime | `ctx.skills.register(...)` |
+| User DSH | `~/.dsh/skills` |
+| User agents | `~/.agents/skills` |
+| Extra | `Config.extraRoots` |
+| System | `~/.dsh/skills/.system` |
+
+The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
+
+Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and disposer calls invalidate the cache; disk-only changes are picked up on the next invalidation or process restart.
+
+## Skill Format
+
+Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter requires `name` and `description`; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
+
+## Prompt Integration
+
+The service listens on `agent/request` and appends a short `## Skills` listing to the request system prompt for the calling agent's cwd. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or local absolute paths. `description` and `whenToUse` are whitespace-normalized and capped in the listing so one pathological skill cannot bloat every model request. Models load full instructions through the `skill` tool.
+
+## System Skills
+
+On startup, the service ensures bundled system skills exist under `~/.dsh/skills/.system` unless `installSystemSkills: false` is configured. Project, runtime, user, and extra-root skills can override system skills by name.

+ 37 - 0
packages/core/skill/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@deepseek-ai/dsh-skill",
+  "description": "Agent skill discovery and prompt listing for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "yaml": "^2.4.2"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 425 - 0
packages/core/skill/src/index.ts

@@ -0,0 +1,425 @@
+/**
+ * Agent skill discovery and prompt listing.
+ *
+ * Skills are progressive-disclosure instructions: the model sees only a short
+ * listing in the system prompt, then calls the `skill` tool to load the full
+ * `SKILL.md` body when a task matches.
+ *
+ * @module @deepseek-ai/dsh-skill
+ */
+
+import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import { homedir } from 'node:os'
+import { Context, Service } from 'cordis'
+import { parse as parseYaml } from 'yaml'
+import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-agent'
+
+const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+const MAX_PROMPT_FIELD_LENGTH = 500
+
+export function isSkillName(name: string): boolean {
+  return SKILL_NAME.test(name)
+}
+
+export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system'
+
+export interface SkillSummary {
+  name: string
+  description: string
+  whenToUse?: string
+  disableModelInvocation?: boolean
+  directory: string
+  source: SkillSource
+}
+
+export interface SkillDefinition extends SkillSummary {
+  content: string
+  path?: string
+  metadata?: Record<string, unknown>
+}
+
+export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
+  disableModelInvocation?: boolean
+}
+
+export interface SkillLookupOptions {
+  cwd?: string | undefined
+}
+
+export interface Config {
+  /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
+  dshHome?: string
+  /** Shared agent config root. Defaults to `~/.agents`. */
+  agentsHome?: string
+  /** Extra skill roots, scanned after user roots and before system skills. */
+  extraRoots?: string[]
+  /** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
+  installSystemSkills?: boolean
+}
+
+declare module 'cordis' {
+  interface Context {
+    skills: SkillService
+  }
+}
+
+interface SkillRoot {
+  path: string
+  source: SkillSource
+  skipSystem?: boolean
+}
+
+const SYSTEM_SKILLS: SkillDefinition[] = [
+  {
+    name: 'dsh-plugin-creator',
+    description: 'Create or update DeepSeek Harness Cordis plugins and packages.',
+    directory: 'system://dsh-plugin-creator',
+    source: 'system',
+    content: [
+      'Use this skill to create DeepSeek Harness plugins that fit the repository architecture.',
+      '',
+      'Prefer Cordis services, plugin packages, effect-scoped registrations, and existing extension seams over loop changes.',
+      'When adding a swappable capability, design the interface/implementation/consumer split first.',
+      'Every registry or registration path needs disposal/HMR coverage.',
+      'Update package docs, architecture docs, package graph references, and generated catalogs when public surfaces change.',
+    ].join('\n'),
+  },
+  {
+    name: 'dsh-skill-creator',
+    description: 'Create or update DeepSeek Harness SKILL.md instructions.',
+    whenToUse: 'Use when writing reusable agent instructions for DeepSeek Harness or adding system/project/user skills.',
+    directory: 'system://dsh-skill-creator',
+    source: 'system',
+    content: [
+      'Use this skill to write focused DeepSeek Harness skills.',
+      '',
+      'A skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.',
+      'Frontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.',
+      'Use optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.',
+      'Keep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.',
+    ].join('\n'),
+  },
+]
+
+export class SkillService extends Service {
+  private readonly dshHome: string
+  private readonly agentsHome: string
+  private readonly extraRoots: string[]
+  private readonly installSystemSkills: boolean
+  private readonly runtime = new Map<string, SkillDefinition>()
+  private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
+  private runtimeRevision = 0
+  private systemReady: Promise<void> | undefined
+
+  constructor(ctx: Context, config: Config = {}) {
+    super(ctx, 'skills')
+    this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
+    this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents'))
+    this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
+    this.installSystemSkills = config.installSystemSkills ?? true
+    if (this.installSystemSkills) {
+      const systemRoot = join(this.dshHome, 'skills/.system')
+      this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
+        this.ctx.logger.warn(`failed to install bundled system skills under ${systemRoot}: ${errorMessage(error)}`)
+      })
+    }
+
+    ctx.on('agent/request', async (agent, _turn, _step, _request, next) => {
+      const listing = await this.renderModelListing({ cwd: agent.session.header.cwd })
+      const result = await next()
+      if (listing.length > 0) appendSystem(result, listing)
+      return result
+    })
+  }
+
+  register(skill: SkillRegistration): () => void {
+    const normalized = normalizeSkill(skill)
+    const dispose = this.ctx.effect(function* (this: SkillService) {
+      this.runtime.set(normalized.name, normalized)
+      this.invalidateCache()
+      yield () => {
+        this.runtime.delete(normalized.name)
+        this.invalidateCache()
+      }
+    }.bind(this), 'skills.register()')
+    return () => void dispose()
+  }
+
+  async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
+    return (await this.collect(options))
+      .filter(skill => skill.disableModelInvocation !== true)
+      .map(toSummary)
+      .sort(compareSummary)
+  }
+
+  async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
+    if (!isSkillName(name)) return undefined
+    return (await this.collect(options)).find(skill => skill.name === name)
+  }
+
+  async renderModelListing(options: SkillLookupOptions = {}): Promise<string> {
+    const skills = await this.list(options)
+    if (skills.length === 0) return ''
+    const entries = skills.map((skill) => {
+      const lines = [
+        `<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
+        `description: ${promptLine(skill.description)}`,
+        ...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
+        '</skill>',
+      ]
+      return lines.join('\n')
+    }).join('\n')
+    return [
+      '## Skills',
+      'Available skills are listed below. Load a skill with the `skill` tool before following its instructions; do not infer or follow instructions from a skill body that has not been loaded.',
+      '<available_skills>',
+      entries,
+      '</available_skills>',
+    ].join('\n')
+  }
+
+  private async collect(options: SkillLookupOptions): Promise<SkillDefinition[]> {
+    await this.ensureSystemSkills()
+    const roots = await this.roots(options.cwd)
+    const key = collectCacheKey(roots, this.runtimeRevision)
+    const cached = this.collectCache.get(key)
+    if (cached !== undefined) return cached
+
+    const collected = this.collectFresh(roots)
+    this.collectCache.set(key, collected)
+    return collected
+  }
+
+  private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
+    const seen = new Set<string>()
+    const result: SkillDefinition[] = []
+
+    const add = (skill: SkillDefinition): void => {
+      if (seen.has(skill.name)) {
+        this.ctx.logger.warn(`skill "${skill.name}" from ${skill.directory} ignored because a higher-priority skill already exists`)
+        return
+      }
+      seen.add(skill.name)
+      result.push(skill)
+    }
+
+    for (const root of roots.project) {
+      for (const skill of await discoverRoot(root, this.ctx)) add(skill)
+    }
+    for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) add(skill)
+    for (const root of roots.shared) {
+      for (const skill of await discoverRoot(root, this.ctx)) add(skill)
+    }
+    return result
+  }
+
+  private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
+    const project: SkillRoot[] = []
+    if (cwd !== undefined) {
+      const projectRoot = await findProjectRoot(resolve(cwd))
+      project.push(
+        { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
+        { path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
+      )
+    }
+    const shared: SkillRoot[] = [
+      { path: join(this.dshHome, 'skills'), source: 'user-dsh', skipSystem: true },
+      { path: join(this.agentsHome, 'skills'), source: 'user-agents' },
+      ...this.extraRoots.map(path => ({ path, source: 'extra' as const })),
+      { path: join(this.dshHome, 'skills/.system'), source: 'system' },
+    ]
+    return { project, shared }
+  }
+
+  private ensureSystemSkills(): Promise<void> {
+    return this.systemReady ?? Promise.resolve()
+  }
+
+  private invalidateCache(): void {
+    this.runtimeRevision += 1
+    this.collectCache.clear()
+  }
+}
+
+async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
+  await mkdir(systemRoot, { recursive: true })
+  await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
+    const dir = join(systemRoot, skill.name)
+    const file = join(dir, 'SKILL.md')
+    try {
+      await access(file)
+      return
+    } catch {
+      // Expected first-run path: the bundled system skill has not been installed.
+    }
+    await mkdir(dir, { recursive: true })
+    await writeFile(file, renderSkillFile(skill))
+    ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
+  }))
+}
+
+function renderSkillFile(skill: SkillDefinition): string {
+  const frontmatter = [
+    '---',
+    `name: ${skill.name}`,
+    `description: ${skill.description}`,
+    ...skill.whenToUse ? [`whenToUse: ${skill.whenToUse}`] : [],
+    '---',
+    '',
+  ]
+  return `${frontmatter.join('\n')}${skill.content}\n`
+}
+
+async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinition[]> {
+  let entries
+  try {
+    entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
+  } catch {
+    return []
+  }
+
+  const skills: SkillDefinition[] = []
+  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
+    if (root.skipSystem && entry.name === '.system') continue
+    const fullPath = join(root.path, entry.name)
+    const parsed = entry.isDirectory()
+      ? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
+      : entry.isFile() && entry.name.endsWith('.md')
+        ? await parseSkillFile(fullPath, root.path, root.source, ctx)
+        : undefined
+    if (parsed) skills.push(parsed)
+  }
+  return skills
+}
+
+async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
+  let raw: string
+  try {
+    raw = await readFile(path, 'utf8')
+  } catch {
+    return undefined
+  }
+  const parsed = parseFrontmatter(raw)
+  if (!parsed) {
+    ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
+    return undefined
+  }
+  const name = stringField(parsed.data, 'name')
+  const description = stringField(parsed.data, 'description')
+  if (name === undefined || description === undefined) {
+    ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
+    return undefined
+  }
+  if (!isSkillName(name)) {
+    ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
+    return undefined
+  }
+  return {
+    name,
+    description,
+    ...optionalString(parsed.data, 'whenToUse'),
+    ...optionalBoolean(parsed.data, 'disableModelInvocation'),
+    ...optionalMetadata(parsed.data),
+    directory,
+    path,
+    source,
+    content: parsed.body.trim(),
+  }
+}
+
+function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
+  if (!raw.startsWith('---\n')) return undefined
+  const end = raw.indexOf('\n---', 4)
+  if (end < 0) return undefined
+  const yaml = raw.slice(4, end)
+  const bodyStart = raw.indexOf('\n', end + 4)
+  const parsed = parseYaml(yaml) as unknown
+  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
+  return { data: parsed as Record<string, unknown>, body: bodyStart < 0 ? '' : raw.slice(bodyStart + 1) }
+}
+
+async function findProjectRoot(cwd: string): Promise<string> {
+  let current = cwd
+  while (true) {
+    try {
+      await access(join(current, '.git'))
+      return current
+    } catch {
+      // Continue walking upward until a git root is found.
+    }
+    const parent = dirname(current)
+    if (parent === current) return cwd
+    current = parent
+  }
+}
+
+function normalizeSkill(skill: SkillRegistration): SkillDefinition {
+  if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
+  if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
+  return { ...skill, source: skill.source }
+}
+
+function toSummary(skill: SkillDefinition): SkillSummary {
+  const { name, description, whenToUse, disableModelInvocation, directory, source } = skill
+  return {
+    name,
+    description,
+    ...whenToUse !== undefined ? { whenToUse } : {},
+    ...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
+    directory,
+    source,
+  }
+}
+
+function compareSummary(left: SkillSummary, right: SkillSummary): number {
+  return left.name.localeCompare(right.name)
+}
+
+function promptLine(value: string): string {
+  const normalized = value.replaceAll(/\s+/g, ' ').trim()
+  if (normalized.length <= MAX_PROMPT_FIELD_LENGTH) return normalized
+  return `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
+}
+
+function stringField(data: Record<string, unknown>, key: string): string | undefined {
+  const value = data[key]
+  return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
+  const value = data[key]
+  return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
+}
+
+function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
+  const value = data[key]
+  return typeof value === 'boolean' ? { [key]: value } : {}
+}
+
+function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
+  const value = data.metadata
+  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+    return { metadata: value as Record<string, unknown> }
+  }
+  return {}
+}
+
+function escapeAttr(value: string): string {
+  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
+}
+
+function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string {
+  return JSON.stringify({ runtimeRevision, roots })
+}
+
+function errorMessage(error: unknown): string {
+  return String(error)
+}
+
+function appendSystem(request: GenerateOptions, text: string): void {
+  request.system = [request.system ?? '', text].filter(part => part.length > 0).join('\n\n')
+}
+
+export default SkillService

+ 342 - 0
packages/core/skill/tests/skill.spec.ts

@@ -0,0 +1,342 @@
+import { describe, expect, it } from 'vitest'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { Context } from 'cordis'
+import SkillService from '@deepseek-ai/dsh-skill'
+
+async function tempDir(name: string): Promise<string> {
+  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+}
+
+async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
+  const dir = join(root, name)
+  await mkdir(dir, { recursive: true })
+  await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
+}
+
+async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
+  await mkdir(root, { recursive: true })
+  await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
+}
+
+describe('SkillService', () => {
+  it('discovers project, user, agents, and system skill roots in priority order', async () => {
+    const home = await tempDir('skill-home')
+    const agentsHome = await tempDir('agents-home')
+    const project = await tempDir('skill-project')
+    await mkdir(join(project, '.git'), { recursive: true })
+
+    await writeSkill(join(home, '.dsh/skills/.system'), 'same', 'system skill')
+    await writeSkill(join(agentsHome, '.agents/skills'), 'same', 'user agents skill')
+    await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
+    await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
+    await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
+    await writeSkill(join(home, '.dsh/skills/.system'), 'system-only', 'system only')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
+
+    const skills = await ctx.skills.list({ cwd: join(project, 'src') })
+    expect(skills.map(skill => [skill.name, skill.description])).toEqual([
+      ['same', 'project dsh skill'],
+      ['system-only', 'system only'],
+    ])
+    expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
+  })
+
+  it('sorts the final model-visible list by skill name after priority conflict resolution', async () => {
+    const home = await tempDir('skill-sorted-home')
+    const agentsHome = await tempDir('skill-sorted-agents')
+    const project = await tempDir('skill-sorted-project')
+    await mkdir(join(project, '.git'), { recursive: true })
+
+    await writeSkill(join(project, '.dsh/skills'), 'z-project', 'Project skill')
+    await writeSkill(join(home, '.dsh/skills'), 'm-user', 'User skill')
+    await writeSkill(join(home, '.dsh/skills/.system'), 'a-system', 'System skill')
+    await writeSkill(join(home, '.dsh/skills/.system'), 'm-user', 'Shadowed system skill')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list({ cwd: project })).map(skill => [skill.name, skill.description])).toEqual([
+      ['a-system', 'System skill'],
+      ['m-user', 'User skill'],
+      ['z-project', 'Project skill'],
+    ])
+  })
+
+  it('gives project skills priority over runtime skills while runtime overrides user and system skills', async () => {
+    const home = await tempDir('skill-runtime-priority')
+    const project = await tempDir('skill-runtime-project')
+    await mkdir(join(project, '.git'), { recursive: true })
+
+    await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
+    await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
+    await writeSkill(join(home, '.dsh/skills/.system'), 'runtime-name', 'System loses')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+    ctx.skills.register({
+      name: 'project-name',
+      description: 'Runtime loses to project',
+      content: 'Runtime body.',
+      directory: 'memory://project-name',
+      source: 'runtime',
+    })
+    ctx.skills.register({
+      name: 'runtime-name',
+      description: 'Runtime wins',
+      content: 'Runtime body.',
+      directory: 'memory://runtime-name',
+      source: 'runtime',
+    })
+
+    expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
+    expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
+  })
+
+  it('does not scan .system twice through the user dsh root', async () => {
+    const home = await tempDir('skill-system')
+    await writeSkill(join(home, '.dsh/skills/.system'), 'builtin', 'builtin skill')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['builtin'])
+  })
+
+  it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
+    const home = await tempDir('skill-flat')
+    await writeFlatSkill(join(home, '.dsh/skills'), 'flat-skill', 'flat description', 'Flat instructions.')
+    await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
+    await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
+    await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
+    await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
+    await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
+    await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
+    await writeFile(join(home, '.dsh/skills/notes.txt'), 'ignored')
+    await mkdir(join(home, '.dsh/skills/not-a-skill'), { recursive: true })
+    await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'hidden description', 'Hidden.')
+    await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body'])
+    expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
+    expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
+  })
+
+  it('renders no model listing when no model-invocable skills exist', async () => {
+    const home = await tempDir('skill-empty-listing')
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect(await ctx.skills.renderModelListing()).toBe('')
+    const request = { model: 'm', messages: [], system: 'base' }
+    const result = await ctx.waterfall('agent/request', {
+      session: { header: { cwd: home } },
+    } as never, 1, 1, request, () => Promise.resolve(request))
+    expect(result.system).toBe('base')
+  })
+
+  it('installs system skills into the DSH home without overwriting existing files', async () => {
+    const home = await tempDir('skill-install')
+    const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
+    await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
+    await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Custom system skill\n---\n\nCustom body.\n')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
+
+    expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
+      ['dsh-plugin-creator', 'Custom system skill'],
+      ['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
+    ])
+    expect(await readFile(existing, 'utf8')).toContain('Custom body.')
+    expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
+  })
+
+  it('degrades when bundled system skill installation fails', async () => {
+    const home = await tempDir('skill-install-fail')
+    await writeFile(join(home, '.dsh'), 'not a directory')
+    await writeSkill(join(home, '.agents/skills'), 'fallback-skill', 'Fallback skill')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fallback-skill'])
+  })
+
+  it('memoizes disk discovery until runtime skill registrations change', async () => {
+    const home = await tempDir('skill-cache')
+    await writeSkill(join(home, '.dsh/skills'), 'initial-skill', 'Initial skill')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
+    await writeSkill(join(home, '.dsh/skills'), 'late-skill', 'Late skill')
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
+
+    const dispose = ctx.skills.register({
+      name: 'runtime-skill',
+      description: 'runtime',
+      content: 'Runtime body.',
+      directory: 'memory://runtime',
+      source: 'runtime',
+    })
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill', 'runtime-skill'])
+
+    dispose()
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill'])
+  })
+
+  it('includes extra roots, optional metadata, and explicit false disable flags', async () => {
+    const home = await tempDir('skill-extra')
+    const extra = await tempDir('skill-extra-root')
+    await writeFile(join(extra, 'extra-skill.md'), [
+      '---',
+      'name: extra-skill',
+      'description: Extra skill',
+      'whenToUse: For extra-root tests',
+      'disableModelInvocation: false',
+      'metadata:',
+      '  owner: tests',
+      '---',
+      '',
+      'Extra body.',
+    ].join('\n'))
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, {
+      dshHome: join(home, '.dsh'),
+      agentsHome: join(home, '.agents'),
+      extraRoots: [extra],
+      installSystemSkills: false,
+    })
+
+    expect(await ctx.skills.list()).toEqual([{
+      name: 'extra-skill',
+      description: 'Extra skill',
+      whenToUse: 'For extra-root tests',
+      disableModelInvocation: false,
+      directory: extra,
+      source: 'extra',
+    }])
+    expect((await ctx.skills.get('extra-skill'))?.metadata).toEqual({ owner: 'tests' })
+    expect(await ctx.skills.renderModelListing()).toContain('whenToUse: For extra-root tests')
+  })
+
+  it('bounds prompt listing fields without changing stored skill content', async () => {
+    const home = await tempDir('skill-prompt-bounds')
+    const longDescription = 'a'.repeat(600)
+    await writeSkill(join(home, '.dsh/skills'), 'long-skill', longDescription, 'Full body.')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    const listing = await ctx.skills.renderModelListing()
+    expect(listing).toContain(`${'a'.repeat(497)}...`)
+    expect(listing).not.toContain('a'.repeat(600))
+    expect((await ctx.skills.get('long-skill'))?.description).toBe(longDescription)
+  })
+
+  it('adds skill guidance through the agent/request waterfall without including bodies', async () => {
+    const home = await tempDir('skill-guidance')
+    await writeSkill(join(home, '.dsh/skills'), 'research-helper', 'Research helper', 'Long body that must not be listed.')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    const request = await ctx.waterfall('agent/request', {
+      session: { header: { cwd: home } },
+    } as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' }))
+
+    expect(request.system ?? '').toContain('## Skills\n')
+    expect(request.system ?? '').toContain('research-helper')
+    expect(request.system ?? '').toContain('source="project-dsh"')
+    expect(request.system ?? '').not.toContain(home)
+    expect(request.system ?? '').not.toContain('Long body')
+    expect((request.system ?? '').match(/## Skills/g)).toHaveLength(1)
+
+    const sameObject = { model: 'm', messages: [], system: 'base' }
+    const sameObjectResult = await ctx.waterfall('agent/request', {
+      session: { header: { cwd: home } },
+    } as never, 1, 1, sameObject, () => Promise.resolve(sameObject))
+    expect(sameObjectResult.system).toContain('## Skills')
+
+    const requestWithoutBase = await ctx.waterfall('agent/request', {
+      session: { header: { cwd: home } },
+    } as never, 1, 1, { model: 'm', messages: [] }, () => Promise.resolve({ model: 'm', messages: [] }))
+    expect(requestWithoutBase.system).toContain('## Skills')
+
+    const copyCtx = new Context()
+    await copyCtx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+    copyCtx.on('agent/request', async (_agent, _turn, _step, requestToCopy) => ({ ...requestToCopy }))
+    const copiedRequest = await copyCtx.waterfall('agent/request', {
+      session: { header: { cwd: home } },
+    } as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' }))
+    expect((copiedRequest.system ?? '').match(/## Skills/g)).toHaveLength(1)
+  })
+
+  it('cleans up runtime registered skills when the contributing fiber is disposed', async () => {
+    const ctx = new Context()
+    const home = await tempDir('skill-runtime')
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      inner.skills.register({
+        name: 'runtime-skill',
+        description: 'runtime',
+        content: 'Runtime body.',
+        directory: 'memory://runtime',
+        source: 'runtime',
+      })
+    }, { inject: ['skills'] }))
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill'])
+    await fiber.dispose()
+    expect(await ctx.skills.list()).toEqual([])
+  })
+
+  it('removes runtime registered skills when the returned disposer is called', async () => {
+    const home = await tempDir('skill-runtime-disposer')
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    const dispose = ctx.skills.register({
+      name: 'manual-dispose',
+      description: 'manual',
+      content: 'Manual body.',
+      directory: 'memory://manual',
+      source: 'runtime',
+    })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['manual-dispose'])
+    dispose()
+    expect(await ctx.skills.list()).toEqual([])
+  })
+
+  it('rejects invalid runtime skill registrations', async () => {
+    const home = await tempDir('skill-runtime-invalid')
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect(() => ctx.skills.register({
+      name: 'Bad_Name',
+      description: 'bad',
+      content: 'bad',
+      directory: 'memory://bad',
+      source: 'runtime',
+    })).toThrow('invalid skill name')
+    expect(() => ctx.skills.register({
+      name: 'empty-description',
+      description: '',
+      content: 'bad',
+      directory: 'memory://bad',
+      source: 'runtime',
+    })).toThrow('requires a description')
+  })
+})

+ 14 - 0
packages/core/skill/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../llm/llm" },
+    { "path": "../agent" }
+  ]
+}

+ 15 - 0
packages/core/tool-skill/README.md

@@ -0,0 +1,15 @@
+# @deepseek-ai/dsh-tool-skill
+
+The model-facing `skill` tool for loading full skill instructions.
+
+Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
+
+## Tool: `skill`
+
+| Arg | Type | Notes |
+|---|---|---|
+| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
+
+Execution uses the calling agent's `session.header.cwd` to resolve project-local skills. A successful call returns a text block containing `<skill_content name="...">`, the skill body, the skill base directory, and relative-path guidance. Unknown names, invalid names, and skills marked `disableModelInvocation: true` return `isError` tool results through the normal tool registry error path.
+
+The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.

+ 38 - 0
packages/core/tool-skill/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@deepseek-ai/dsh-tool-skill",
+  "description": "Model-facing skill loading tool for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-skill": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-skill": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 52 - 0
packages/core/tool-skill/src/index.ts

@@ -0,0 +1,52 @@
+/**
+ * Model-facing `skill` tool.
+ *
+ * @module @deepseek-ai/dsh-tool-skill
+ */
+
+import type { Context } from 'cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { isSkillName, type SkillDefinition } from '@deepseek-ai/dsh-skill'
+
+export const name = 'tool-skill'
+export const inject = ['tools', 'skills']
+
+export function apply(ctx: Context): void {
+  const skillTool = defineTool({
+    name: 'skill',
+    description: 'Load the full instructions for one available skill by name. Use this when the current task matches a skill listed in the system prompt.',
+    parameters: {
+      name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
+    },
+    async execute(args, exec) {
+      if (!isSkillName(args.name)) {
+        throw new Error(`invalid skill name "${args.name}"`)
+      }
+      const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd })
+      if (!skill) {
+        throw new Error(`unknown skill "${args.name}"`)
+      }
+      if (skill.disableModelInvocation === true) {
+        throw new Error(`skill "${args.name}" is not available for model invocation`)
+      }
+      return [{ type: 'text', text: renderSkillContent(skill) }]
+    },
+    presentCall(args) {
+      return { title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
+    },
+  })
+  ctx.tools.register(skillTool)
+}
+
+function renderSkillContent(skill: SkillDefinition): string {
+  return [
+    `<skill_content name="${skill.name}">`,
+    `# Skill: ${skill.name}`,
+    '',
+    skill.content,
+    '',
+    `Base directory for this skill: ${skill.directory}`,
+    'Resolve relative files mentioned by this skill against the base directory before using them.',
+    '</skill_content>',
+  ].join('\n')
+}

+ 86 - 0
packages/core/tool-skill/tests/tool-skill.spec.ts

@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import SkillService from '@deepseek-ai/dsh-skill'
+import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
+
+async function tempDir(name: string): Promise<string> {
+  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+}
+
+async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
+  const dir = join(root, name)
+  await mkdir(dir, { recursive: true })
+  await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
+}
+
+async function setup(home: string): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+  await ctx.plugin(toolSkill)
+  return ctx
+}
+
+describe('dsh-tool-skill', () => {
+  it('registers the skill tool schema and removes it on dispose', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    const home = await tempDir('tool-schema')
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    const fiber = await ctx.plugin(toolSkill)
+    expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
+    expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
+      title: 'Load skill project-skill',
+      kind: 'read',
+      rawInput: 'project-skill',
+    })
+    await fiber.dispose()
+    expect(ctx.tools.schemas()).toEqual([])
+  })
+
+  it('loads a skill for the calling agent cwd', async () => {
+    const home = await tempDir('tool-load')
+    const project = await tempDir('tool-project')
+    await mkdir(join(project, '.git'), { recursive: true })
+    await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
+    const ctx = await setup(home)
+
+    const result = await ctx.tools.execute({
+      callId: CallId('c1'),
+      name: 'skill',
+      arguments: { name: 'project-skill' },
+      agent: { session: { header: { cwd: project } } } as never,
+    })
+
+    expect(result.isError).toBe(false)
+    const block = result.content[0]
+    expect(block?.type).toBe('text')
+    if (block?.type !== 'text') throw new Error('expected text skill result')
+    expect(block.text).toContain('<skill_content name="project-skill">')
+    expect(block.text).toContain('Project instructions.')
+  })
+
+  it('returns isError for unknown, invalid, and model-disabled skills', async () => {
+    const home = await tempDir('tool-errors')
+    await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
+    await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
+    const ctx = await setup(home)
+
+    const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
+    const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
+    const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
+
+    expect(unknown.isError).toBe(true)
+    expect(invalid.isError).toBe(true)
+    expect(disabled.isError).toBe(true)
+  })
+})

+ 16 - 0
packages/core/tool-skill/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../llm/llm" },
+    { "path": "../agent" },
+    { "path": "../skill" },
+    { "path": "../tools" }
+  ]
+}

+ 3 - 1
packages/ui/stdio-agent/README.md

@@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
 | Plugin | Why it is here |
 |---|---|
 | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
-| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` |
+| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` with `process.cwd()` as the fresh session cwd |
 | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
 | `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
 
@@ -29,6 +29,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 | `welcome` | `ready.` | the stdin-chat banner |
 | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
 
+Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header.
+
 ## The bin
 
 `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way.

+ 5 - 2
packages/ui/stdio-agent/src/index.ts

@@ -48,8 +48,10 @@ export const name = 'stdio-agent'
 /**
  * App config: the swappable per-demo values, each routed to where the app wires
  * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
- * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list);
- * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
+ * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list).
+ * Fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
+ * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
+ * `welcome` is the UI banner.
  */
 export interface Config {
   /** Model name for the `main` agent (must have a registered adapter). */
@@ -90,6 +92,7 @@ export function apply(ctx: Context, config: Config): void {
       id: AgentId('main'),
       model: config.model,
       systemPrompt: config.systemPrompt,
+      cwd: process.cwd(),
       ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
     }],
   })

+ 3 - 1
packages/ui/stdio-agent/tests/stdio-agent.spec.ts

@@ -34,7 +34,9 @@ describe('dsh-stdio-agent app', () => {
     expect(ctx.get('agentLoop')).toBeDefined()
     expect(ctx.get('sessionPersistence')).toBeDefined()
     // The pre-created `main` agent the UI drives.
-    expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
+    const agent = ctx.get('agents')?.get(AgentId('main'))
+    expect(agent).toBeDefined()
+    expect(agent?.session.header.cwd).toBe(process.cwd())
     await ctx.fiber.dispose()
   })
 

+ 40 - 0
pnpm-lock.yaml

@@ -153,12 +153,18 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../session
+      '@deepseek-ai/dsh-skill':
+        specifier: workspace:^
+        version: link:../skill
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../system-prompt
       '@deepseek-ai/dsh-tool-bash':
         specifier: workspace:^
         version: link:../../bash/tool-bash
+      '@deepseek-ai/dsh-tool-skill':
+        specifier: workspace:^
+        version: link:../tool-skill
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../tools
@@ -212,6 +218,22 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/core/skill:
+    dependencies:
+      yaml:
+        specifier: ^2.4.2
+        version: 2.9.0
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/core/system-prompt:
     devDependencies:
       '@deepseek-ai/dsh-llm':
@@ -221,6 +243,24 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/core/tool-skill:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-skill':
+        specifier: workspace:^
+        version: link:../skill
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../tools
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/core/tools:
     devDependencies:
       '@deepseek-ai/dsh-agent':

+ 2 - 0
tsconfig.build.json

@@ -19,6 +19,8 @@
     { "path": "./packages/core/system-prompt" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/core/tools" },
+    { "path": "./packages/core/skill" },
+    { "path": "./packages/core/tool-skill" },
     { "path": "./packages/core/agent-loop" },
     { "path": "./packages/core/agent-core" },
     { "path": "./packages/bash/bash" },

+ 2 - 0
tsconfig.json

@@ -30,6 +30,8 @@
     { "path": "./packages/core/system-prompt" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/core/tools" },
+    { "path": "./packages/core/skill" },
+    { "path": "./packages/core/tool-skill" },
     { "path": "./packages/core/agent-loop" },
     { "path": "./packages/core/agent-core" },
     { "path": "./packages/bash/bash" },