Merge remote-tracking branch 'origin/master' into codex/pr239-parent-retarget-publish

# Conflicts:
#	docs/cookbook/extension-cookbook.i18n.yaml
#	examples/acp-agent/tests/acp.snapshot.ts
#	examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
This commit is contained in:
Tianyi Cui
2026-07-21 00:54:51 +08:00
125 changed files with 1964 additions and 145 deletions

View File

@@ -523,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),

View File

@@ -4,10 +4,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + command registry + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.

View File

@@ -12,6 +12,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch |
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
@@ -37,6 +38,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
@@ -57,7 +59,7 @@ All diagnostics go to **stderr** — stdout is the protocol.
## Model Experience
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots.
#### KV Cache effect

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -13,6 +13,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -61,6 +62,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Bounded transient model-request retry policy forwarded through agent-core. */
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
}
@@ -85,6 +88,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
llmRetry: agentCore.LlmRetryConfigSchema,
})
/* jscpd:ignore-end */
@@ -97,8 +101,10 @@ export const Config: z<Config> = z.object({
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
const goals = config.goals ?? {}
ctx.plugin(CommandService)
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,

View File

@@ -86,11 +86,30 @@ describe('dsh-acp-demo composition', () => {
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()
})
it('can explicitly omit the persisted-goal stack and its command', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
goals: false,
workspaceContext: false,
})
expect(ctx.get('goals')).toBeUndefined()
const handle = await ctx.agents.create({
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
await handle.dispose()
await ctx.fiber.dispose()
})
it('defaults the persistence root when omitted', async () => {
// Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that
// bypasses the schema's `.default(...)`: call `apply` directly (not via
@@ -188,7 +207,17 @@ describe('dsh-acp-demo composition', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
expect(assembly.tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'create_goal',
'get_goal',
'skill',
'task_kill',
'task_list',
'task_output',
'update_goal',
])
await ctx.fiber.dispose()
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../../core/agent"
},

View File

@@ -17,6 +17,9 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
@deepseek-ai/dsh-goal optional persisted same-session goal domain
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@@ -43,11 +46,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, llmRetry? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, llmRetry? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -57,7 +60,7 @@ The bounded retry policy may repeat a transiently failed request in a new number
## Model Experience
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content.
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own.
#### KV Cache effect
@@ -65,5 +68,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
"description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,8 @@
"@cordisjs/plugin-timer": "^1.1.2",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-goal-session": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
@@ -36,6 +38,7 @@
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -45,6 +48,8 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
@@ -57,6 +62,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* Default executor-less, UI-less agent spine. It bundles the common services,
* background-task registry and controls, concrete loop, local skill and
* background-task registry and controls, optional persisted goals, concrete loop, local skill and
* workspace-context providers, and model-facing bash/skill consumers;
* deployments still choose the LLM adapter, bash executor, and presentation.
* The plugin intentionally exposes named exports only because Loader default
@@ -18,6 +18,9 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -42,6 +45,14 @@ export interface SkillConfig {
tool?: toolSkill.Config
}
/** Persisted goal domain, model-tool policy, and same-session driver config. */
export interface GoalConfig {
/** Goal-domain creation defaults. */
domain?: GoalDomainConfig
/** Model-facing goal-tool authority policy. */
tool?: toolGoal.Config
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
@@ -50,8 +61,10 @@ export interface SkillConfig {
* order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* `goals` opts into and configures the persisted goal
* domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
@@ -78,6 +91,8 @@ export interface Config {
toolBash?: toolBash.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
goals?: GoalConfig | false
/** Bounded transient model-request retry policy. */
llmRetry?: llmRetry.Config
}
@@ -96,6 +111,12 @@ export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
/** The persisted-goal config schema exported for app packages that opt in. */
export const GoalConfigSchema: z<GoalConfig> = z.object({
domain: GoalService.Config,
tool: toolGoal.Config,
})
/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */
export const LlmRetryConfigSchema: z<llmRetry.Config> = llmRetry.Config
@@ -110,8 +131,9 @@ export const Config = z.intersect([
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
goals: z.union([z.const(false), GoalConfigSchema]),
llmRetry: LlmRetryConfigSchema,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'llmRetry'>>,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals' | 'llmRetry'>>,
]) as unknown as z<Config>
/**
@@ -130,6 +152,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
...config.goals !== undefined ? { goals: config.goals } : {},
...config.llmRetry !== undefined ? { llmRetry: config.llmRetry } : {},
}
}
@@ -168,6 +191,11 @@ export function apply(ctx: Context, config: Config): void {
}
ctx.plugin(AgentRegistry)
ctx.plugin(llmRetry, config.llmRetry ?? {})
if (config.goals !== undefined && config.goals !== false) {
ctx.plugin(GoalService, config.goals.domain ?? {})
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))

View File

@@ -124,6 +124,35 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('goals')).toBeUndefined()
await ctx.fiber.dispose()
})
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
const ctx = await mount({
workspaceContext: false,
agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }],
goals: {
domain: { defaultMaxGoalRounds: 17 },
tool: { blockedAfterConsecutiveRounds: 5 },
},
})
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('configured goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({
objective: 'configured', maxGoalRounds: 17,
})
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
.toEqual(['create_goal', 'get_goal', 'update_goal'])
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:goal')?.text)
.toContain('at least 5 consecutive goal rounds')
await ctx.fiber.dispose()
})
it('accepts an explicit false goal composition without mounting it', async () => {
const ctx = await mount({ workspaceContext: false, goals: false })
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.tools.get('get_goal')).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -211,6 +240,23 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => {
const ctx = new Context()
agentCore.apply(ctx, {
workspaceContext: false,
agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }],
goals: {},
})
await new Promise(resolve => setTimeout(resolve, 50))
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('default goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({
objective: 'defaulted', maxGoalRounds: 256,
})
expect(ctx.tools.get('get_goal')).toBeDefined()
await ctx.fiber.dispose()
})
it('loads workspace instructions into requests through the bundled spine', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-'))
try {

View File

@@ -41,6 +41,15 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../goal/tool-goal"
},
{
"path": "../../goal/goal-session"
},
{
"path": "../../context/workspace-context"
},

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tui-demo
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), the human-command registry, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
@@ -10,6 +10,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
@@ -31,6 +32,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
| `skills` | owner defaults | Skill registry, local provider, and tool config |
| `toolBash` | owner defaults | Model-facing bash tool config |
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `workspaceContext` | required | Workspace-instruction config, or `false` |
| `persistenceRoot` | `./.sessions` | JSONL persistence root |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
@@ -71,7 +73,7 @@ Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI a
#### What the model sees
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible.
#### Token effect

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen terminal app: agent spine + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
@@ -55,6 +56,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -1,8 +1,8 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
* plus human commands, JSONL persistence, keyboard-backed user interaction,
* and one pre-created agent whose exact session identity the TUI drives. Swappable adapters,
* executors, optional tools, and HMR stay in the leaf. This Loader plugin
* plus persisted goals, human commands, JSONL persistence, keyboard-backed
* user interaction, and one pre-created agent whose exact session identity the
* TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin
* intentionally exposes named exports only; a default export would hide its
* `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-tui-demo
@@ -14,6 +14,7 @@ import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl, {
@@ -58,6 +59,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Persisted session id to resume instead of creating a fresh session. */
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -83,6 +86,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
@@ -98,7 +102,9 @@ export const Config: z<Config> = z.object({
export function composeTuiApp(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
const goals = config.goals ?? {}
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
@@ -111,6 +117,7 @@ export function composeTuiApp(ctx: Context, config: Config): void {
})
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
goals,
agents: [{
id: SessionId('main'),
provider: config.provider,

View File

@@ -42,6 +42,7 @@ describe('dsh-tui-demo app', () => {
expect(calls.map(call => call.name)).toEqual([
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'UserInteractionService',
'ui-tui',
@@ -49,12 +50,13 @@ describe('dsh-tui-demo app', () => {
'tool-ask-user',
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[1]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[3]?.config as { sessionId: string }
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[4]?.config as {
const spineConfig = calls[5]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
readonly persona: string
readonly toolOrder: string[]
@@ -65,6 +67,7 @@ describe('dsh-tui-demo app', () => {
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
goals: {},
})
expect(spineConfig.agents[0]).toMatchObject({
id: 'main',
@@ -84,9 +87,9 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
expect(calls[1]?.config).toEqual({ root: './.sessions' })
expect(calls[3]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
@@ -98,6 +101,7 @@ describe('dsh-tui-demo app', () => {
provider: 'mock',
model: 'mock-model',
resumeSessionId: '',
goals: false,
workspaceContext: false,
})
@@ -105,6 +109,8 @@ describe('dsh-tui-demo app', () => {
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[4]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {

View File

@@ -29,6 +29,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../agent-spine-demo"
},

View File

@@ -7,5 +7,6 @@ The goal family owns durable objective state independently of the model-facing t
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — |
| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — |
| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — |
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.

View File

@@ -0,0 +1,56 @@
# @deepseek-ai/dsh-command-goal
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
## Command contract
| Input | Result |
|---|---|
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. |
| `/goal <objective>` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. |
| `/goal edit <objective>` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. |
| `/goal pause` | Pause an active goal and disarm continuation. |
| `/goal resume` | Resume a stopped goal or rearm an active goal after session resume/fork, subject to its remaining round cap. |
| `/goal clear` | Clear the current pointer while retaining its durable history and tombstone. |
Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear.
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
## Composition
The producer injects `commands` and `goals`. A custom app mounts their owners plus this plugin; automatic continuation remains an independent choice:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: goal
name: '@deepseek-ai/dsh-goal'
- id: command-goal
name: '@deepseek-ai/dsh-command-goal'
```
The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
## Model Experience
### Human `/goal` control
#### What the model sees
The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
#### Token effect
Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts.
#### KV Cache effect
Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix.
## Known Limitations and Deferred Work
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP.
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
- **TUI and ACP only** — the headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed.

View File

@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-command-goal",
"description": "Human-facing slash command for persisted same-session goals",
"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-commands": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,170 @@
/**
* Human-facing `/goal` command over the persisted same-session goal domain.
* @module @deepseek-ai/dsh-command-goal
*/
import type { Context } from 'cordis'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
import { GoalError } from '@deepseek-ai/dsh-goal'
import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
export const name = 'command-goal'
export const inject = ['commands', 'goals']
const USAGE = 'Usage: /goal [<objective>|clear|edit <objective>|pause|resume]'
type GoalCommand =
| { readonly kind: 'show' }
| { readonly kind: 'create'; readonly objective: string }
| { readonly kind: 'edit'; readonly objective: string }
| { readonly kind: 'invalid-edit' }
| { readonly kind: 'pause' }
| { readonly kind: 'resume' }
| { readonly kind: 'clear' }
/** Fail loudly if a locally closed union gains an unhandled member. */
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
function assertNever(value: never, label: string): never {
throw new TypeError(`unknown ${label}: ${String(value)}`)
}
/* v8 ignore stop */
/** Parse only the grammar owned by `/goal`; arbitrary other input is an objective. */
function parseGoalCommand(rawInput: string): GoalCommand {
const input = rawInput.trim()
if (input.length === 0) return { kind: 'show' }
const control = input.toLowerCase()
if (control === 'clear') return { kind: 'clear' }
if (control === 'pause') return { kind: 'pause' }
if (control === 'resume') return { kind: 'resume' }
if (control === 'edit') return { kind: 'invalid-edit' }
if (/^edit(?=\s)/iu.test(input)) return { kind: 'edit', objective: input.slice(4).trim() }
return { kind: 'create', objective: input }
}
/** Human label for one durable goal phase. */
function phaseLabel(phase: GoalPhase): string {
switch (phase) {
case 'active': return 'active'
case 'paused': return 'paused'
case 'blocked': return 'blocked'
case 'complete': return 'complete'
/* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */
default: return assertNever(phase, 'goal phase')
}
}
/** Commands that are meaningful from one exact live state. */
function commandHint(goal: GoalView): string {
if (goal.phase === 'active') {
return goal.activation === 'armed'
? '/goal edit <objective>, /goal pause, /goal clear'
: '/goal edit <objective>, /goal resume, /goal clear'
}
switch (goal.phase) {
case 'paused':
case 'blocked':
return '/goal edit <objective>, /goal resume, /goal clear'
case 'complete':
return '/goal <objective>, /goal clear'
/* v8 ignore next 2 -- the active branch and every non-active phase are handled above */
default: return assertNever(goal.phase, 'goal phase')
}
}
/** Render direct UI output without exposing compare-and-set internals. */
function renderGoal(title: string, goal: GoalView): CommandResult {
const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined
/* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */
if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason')
const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`]
return {
kind: 'success',
text: [
title,
`Status: ${phaseLabel(goal.phase)}`,
...blocker,
`Objective: ${goal.objective}`,
`Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`,
`Activation: ${goal.activation}`,
'',
`Commands: ${commandHint(goal)}`,
].join('\n'),
}
}
/** Exact current compare-and-set ref. */
function goalRef(goal: GoalView): GoalRef {
return { id: goal.id, revision: goal.revision }
}
/** Direct error for an operation that requires a current goal. */
function missingGoal(action: string): CommandResult {
return {
kind: 'error',
text: `No goal is currently set; /goal ${action} requires one. ${USAGE}`,
}
}
/** Execute one parsed human command through the domain that owns persistence. */
function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult {
const command = parseGoalCommand(invocation.rawInput)
try {
const current = ctx.goals.get(invocation.agent)
switch (command.kind) {
case 'show':
return current === undefined
? { kind: 'success', text: `No goal is currently set.\n${USAGE}` }
: renderGoal('Goal', current)
case 'invalid-edit':
return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` }
case 'create':
if (current !== undefined && current.phase !== 'complete') {
return {
kind: 'error',
text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit <objective> to change it or /goal clear before replacing it.`,
}
}
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
case 'edit':
if (current === undefined) return missingGoal('edit')
if (current.phase === 'complete') {
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
}
return renderGoal(
'Goal updated',
ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }),
)
case 'pause':
if (current === undefined) return missingGoal('pause')
return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current)))
case 'resume':
if (current === undefined) return missingGoal('resume')
return renderGoal('Goal resumed', ctx.goals.resume(invocation.agent, goalRef(current)))
case 'clear':
if (current === undefined) return { kind: 'success', text: 'No goal to clear.' }
ctx.goals.clear(invocation.agent, goalRef(current))
return { kind: 'success', text: 'Goal cleared.' }
/* v8 ignore next 2 -- GoalCommand is closed and every member is handled above */
default: return assertNever(command, 'goal command')
}
} catch (error: unknown) {
if (error instanceof GoalError) {
return {
kind: 'error',
text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
}
}
throw error
}
}
/** Register the Codex-shaped `/goal` command for every composed command adapter. */
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'goal',
description: 'set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
handler: invocation => executeGoalCommand(ctx, invocation),
})
}

View File

@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly session: Session
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Number the next balanced injection or message turn. */
function nextTurn(session: Session): number {
return session.events.reduce(
(maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
0,
) + 1
}
/** Append one idle injection using the public Agent contract's balanced shape. */
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
const source: MessageSource = options?.source ?? { kind: 'user' }
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a live idle agent accepted by the exact-identity goal service. */
function stubAgent(id: string): { agent: Agent; session: Session } {
const session = new Session(SessionId(id))
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
ctx: new Context(),
get status() { return status },
send() {},
steer() {},
inject(content, options) { appendInjection(session, content, options) },
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
return { agent, session }
}
/** Mount the real command registry, goal domain, and producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const plugin = await ctx.plugin(commandGoal)
const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
/** Execute `/goal` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const result = await test.ctx.commands.execute(
test.agent,
`/goal${suffix}`,
new AbortController().signal,
)
if (result === undefined) throw new Error('goal command was not registered')
return result
}
/** Current exact compare-and-set ref. */
function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
return { id: goal.id, revision: goal.revision }
}
describe('@deepseek-ai/dsh-command-goal registration', () => {
it('registers one global command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandGoal.name).toBe('command-goal')
expect(commandGoal.inject).toEqual(['commands', 'goals'])
expect('default' in commandGoal).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'goal',
description: 'set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
})
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
})
})
describe('/goal human command', () => {
it('shows an empty status without mutating the session', async () => {
const test = await harness()
await expect(run(test)).resolves.toEqual({
kind: 'success',
text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
})
expect(test.session.events).toEqual([])
})
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
const test = await harness()
const created = await run(test, '\n finish the release ')
expect(created.kind).toBe('success')
expect(created.text).toContain('Goal created\nStatus: active')
expect(created.text).toContain('Objective: finish the release')
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
const count = test.session.events.length
await expect(run(test, ' replacement')).resolves.toEqual({
kind: 'error',
text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
})
expect(test.session.events).toHaveLength(count)
})
it('treats only exact control words as controls', async () => {
const test = await harness()
await run(test, ' pause everything only after verification')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification')
})
it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => {
const empty = await harness()
const invalidEdit = await run(empty, ' edit')
expect(invalidEdit.kind).toBe('error')
expect(invalidEdit.text).toContain('requires a replacement objective')
const missingEdit = await run(empty, ' edit replacement')
expect(missingEdit.kind).toBe('error')
expect(missingEdit.text).toContain('/goal edit requires one')
const test = await harness()
await run(test, ' first')
const first = test.ctx.goals.get(test.agent)!
const updated = await run(test, ' EDIT\n second ')
expect(updated.kind).toBe('success')
expect(updated.text).toContain('Goal updated')
expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 })
const current = test.ctx.goals.get(test.agent)!
test.ctx.goals.complete(test.agent, ref(current))
const replacement = await run(test, ' edit third')
expect(replacement.kind).toBe('success')
expect(replacement.text).toContain('Goal created')
expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 })
expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id)
})
it('returns direct missing-state results for pause, resume, and clear', async () => {
const test = await harness()
const missingPause = await run(test, ' pause')
expect(missingPause.kind).toBe('error')
expect(missingPause.text).toContain('/goal pause requires one')
const missingResume = await run(test, ' resume')
expect(missingResume.kind).toBe('error')
expect(missingResume.text).toContain('/goal resume requires one')
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' })
})
it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => {
const test = await harness()
await run(test, ' work')
const redundantResume = await run(test, ' RESUME')
expect(redundantResume).toEqual({
kind: 'error',
text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
})
const paused = await run(test, ' PAUSE')
expect(paused.kind).toBe('success')
expect(paused.text).toContain('Goal paused')
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' })
const resumed = await run(test, ' resume')
expect(resumed.kind).toBe('success')
expect(resumed.text).toContain('Goal resumed')
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' })
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' })
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
})
it('shows every durable phase and distinguishes disarmed active state', async () => {
const test = await harness()
test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 })
test.ctx.goals.disarm(test.agent)
expect((await run(test)).text)
.toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed')
expect((await run(test)).text).toContain('/goal resume')
let goal = test.ctx.goals.get(test.agent)!
goal = test.ctx.goals.resume(test.agent, ref(goal))
goal = test.ctx.goals.pause(test.agent, ref(goal))
expect((await run(test)).text).toContain('Status: paused')
goal = test.ctx.goals.resume(test.agent, ref(goal))
goal = test.ctx.goals.block(test.agent, ref(goal), {
code: 'upstream-unavailable',
message: 'Provider unavailable',
})
const blocked = await run(test)
expect(blocked.text).toContain('Status: blocked')
expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
goal = test.ctx.goals.resume(test.agent, ref(goal))
test.ctx.goals.complete(test.agent, ref(goal))
const complete = await run(test)
expect(complete.text).toContain('Status: complete')
expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')
})
it('does not turn unexpected implementation failures into expected command results', async () => {
const test = await harness()
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') })
await expect(run(test)).rejects.toThrow('unexpected failure')
})
})

View File

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

View File

@@ -71,6 +71,6 @@ Schemas are prefix-stable while their definitions and visibility are unchanged.
- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal.
- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred.
- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers.
- **No scheduling or direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain.
- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them.
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.