Restore Cordis mount and unmount names

Keep temporary lifecycle descriptions and UI labels explicit.
This commit is contained in:
NI0317
2026-07-27 21:20:06 +08:00
parent 826c6fd243
commit 1ef285d038
80 changed files with 578 additions and 567 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 4e12b82070bd27a0724cc3e43e952a4221339f49
README.zh.md: b4936501b68e110ff538fec28bb52a3a72d7b849
README.md: 32651291253077098bc43a930cf4ce11d29b1ed8
README.zh.md: ea8f398541d7af6136b29c3365a78c4ea3a1e85d

View File

@@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` with a shared Cordis accent; try keeps the code variant's expandable source rendering.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -8,7 +8,7 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Try temporary Plugin``Stop temporary Plugin`try 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。

View File

@@ -37,15 +37,15 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
edit: 'edit',
run_code: 'code',
cordis_inspect: 'read',
cordis_try: 'code',
cordis_stop: 'others',
cordis_mount: 'code',
cordis_unmount: 'others',
}
/** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_try: 'Try temporary Plugin',
cordis_stop: 'Stop temporary Plugin',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
}
/**

View File

@@ -172,21 +172,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_try', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_stop', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was stopped and removed.'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const tried = nest.querySelector('[data-variant="code"]')
expect(tried?.textContent).toContain(`Try temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_stop"]')?.textContent)
.toContain('Stop temporary Plugindyn-2')
const mounted = nest.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(tried!.querySelector('button[aria-expanded]')!)
expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code)
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {

View File

@@ -32,8 +32,8 @@ describe('tool-call-model', () => {
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read')
expect(classifyTool('cordis_try')).toBe('code')
expect(classifyTool('cordis_stop')).toBe('others')
expect(classifyTool('cordis_mount')).toBe('code')
expect(classifyTool('cordis_unmount')).toBe('others')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -80,20 +80,20 @@ describe('tool-call-model', () => {
title: 'Inspect',
summary: 'api',
})
expect(toolRowModel('cordis_try', running({
name: 'cordis_try',
expect(toolRowModel('cordis_mount', running({
name: 'cordis_mount',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
}))).toMatchObject({
variant: 'code',
title: 'Try temporary Plugin',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_stop', result({
call: { name: 'cordis_stop', argsRaw: '{"id":"dyn-2"}' },
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Stop temporary Plugin',
title: 'Unmount temporary Plugin',
summary: 'dyn-2',
})
})

View File

@@ -166,19 +166,19 @@ describe('keyed toolview hole through the real machinery', () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_try', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
])
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const tried = view.container.querySelector('[data-variant="code"]')
expect(tried?.textContent).toContain(`Try temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_stop"]')?.textContent)
.toContain('Stop temporary Plugindyn-2')
const mounted = view.container.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(tried!.querySelector('button[aria-expanded]')!)
expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code)
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/README.md
README.md: bcd11230cdaaaf2893bcc64eadfe689855bc3553
README.zh.md: c26424f08dced26aa60d0a21202449e1b3b16860
README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365
README.zh.md: cc91e68f0dfa9fb343c332eba2579c2a077beb64

View File

@@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_try` / `cordis_stop` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |

View File

@@ -2,8 +2,8 @@
[English](README.md) | 中文
面向模型、作用于 agent智能体所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并尝试或停止仅存于内存的临时 Plugin。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
面向模型、作用于 agent智能体所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并挂载或卸载仅存于内存的临时 Plugin。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
| 包package | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_try``cordis_stop` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
README.md: fda296817026556f235d42626e87fe1f361f2c36
README.zh.md: b8e3c02ba43a7c366664f5964168798ee7356186
README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97
README.zh.md: 11e5be11dca84247b19888fefa7d70e5d74da174

View File

@@ -6,15 +6,15 @@ The self-referential Cordis toolset: three model-facing tools over the live runt
## What it does
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_try` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_try` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-<n>`.
- `cordis_stop` — stops one `dyn-<n>` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins.
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_mount` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_mount` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-<n>`.
- `cordis_unmount` — unmounts one `dyn-<n>` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins.
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
Canonical successes are the inspection string, try `{ id, pluginName, state, provides, waitingFor }`, and stop `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until stopped or DSH restarts; stop confirms that it was stopped and removed.
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_stop`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow.
## Trust stance
@@ -32,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
## Rendering
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_try` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
## Export shape
@@ -44,7 +44,7 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
#### What the model sees
The conversation model sees the generated [`cordis_inspect`, `cordis_try`, and `cordis_stop` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### Token effect
@@ -58,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha
#### What the model sees
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until stopped or DSH restart. The empty state explains that `cordis_try` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Try returns `Temporary Plugin <id> is running (...)` or `Temporary Plugin <id> is pending (...)`; stop returns `Temporary Plugin <id> was stopped and removed.` The submitted program remains in assistant tool-call history.
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until unmounted or DSH restart. The empty state explains that `cordis_mount` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `Temporary Plugin <id> is running (...)` or `Temporary Plugin <id> is pending (...)`; unmount returns `Temporary Plugin <id> was unmounted and removed.` The submitted program remains in assistant tool-call history.
#### Token effect
@@ -68,11 +68,11 @@ Inspect output and mount code are data-dependent and resent until compaction; li
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after cordis_try
### Later requests after cordis_mount
#### What the model sees
A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` removes those contributions after quiescence.
A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_unmount` removes those contributions after quiescence.
#### Token effect
@@ -80,7 +80,7 @@ Indirect token impact equals the temporary Plugin's contributions and lasts only
#### KV Cache effect
Trying or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable.
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable.
## Known Limitations and Deferred Work

View File

@@ -6,15 +6,15 @@
## 功能
- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_try` 临时 Plugin 子集,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_try`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-<n>` 跟踪、仅存于内存的临时 Plugin。
- `cordis_stop`停止一个 `dyn-<n>` 临时 Plugin并只在其自有效果完全停稳后返回它不能删除 Loader、配置或已安装的 Plugin。
- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_mount` 临时 Plugin 子集,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_mount`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-<n>` 跟踪、仅存于内存的临时 Plugin。
- `cordis_unmount`卸载一个 `dyn-<n>` 临时 Plugin并只在其自有效果完全停稳后返回它不能删除 Loader、配置或已安装的 Plugin。
精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
规范成功值分别为检查字符串、尝试 `{ id, pluginName, state, provides, waitingFor }`,以及停止 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被停止或 DSH 重启;停止结果确认它已停止并移除。
规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。
临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session但会在 `cordis_stop`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。
临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。
## 信任立场
@@ -32,7 +32,7 @@
## 渲染
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_try``rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_mount``rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
## 导出形状
@@ -44,7 +44,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_try` 和 `cordis_stop` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
#### Token 影响
@@ -58,7 +58,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 runningpending 状态、提供与等待的服务,以及持续至停止或 DSH 重启的生命周期;空状态说明 `cordis_try` Plugin 会在重启时消失。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。尝试结果为 `Temporary Plugin <id> is running (...)``Temporary Plugin <id> is pending (...)`停止结果为 `Temporary Plugin <id> was stopped and removed.`。提交的程序保留在 assistant 工具调用历史中。
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 runningpending 状态、提供与等待的服务,以及持续至卸载或 DSH 重启的生命周期;空状态说明 `cordis_mount` Plugin 会在重启时消失。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `Temporary Plugin <id> is running (...)``Temporary Plugin <id> is pending (...)`卸载返回 `Temporary Plugin <id> was unmounted and removed.`。提交的程序保留在 assistant 工具调用历史中。
#### Token 影响
@@ -68,11 +68,11 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
### cordis_try 后的后续请求
### cordis_mount 后的后续请求
#### 模型看到的内容
临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 会在完全停稳后移除这些贡献。
临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。
#### Token 影响
@@ -80,7 +80,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### KV Cache 影响
尝试或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。
## 已知限制与暂缓事项

View File

@@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context {
if (ctx.get(prop) !== undefined) {
throw new Error(
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this temporary Plugin if the provider later stops.',
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.',
)
}
throw new Error(

View File

@@ -1,6 +1,6 @@
/**
* Self-referential runtime tools: inspect live services/plugins/tools, try a returned temporary
* plugin under an owned dynamic fiber, and stop it to quiescence. Registrations are fiber effects,
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary
* plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
* accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real
* runtime. Named exports preserve loader injection metadata.
@@ -15,7 +15,7 @@ import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentStopCall, presentTryCall } from './present.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
@@ -60,10 +60,10 @@ export function apply(ctx: Context, config: Config): void {
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (all live plugin fibers with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), '
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. '
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. '
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'to narrow to one service/event and include its original source JSDoc.',
@@ -106,11 +106,11 @@ export function apply(ctx: Context, config: Config): void {
}))
ctx.tools.register(defineTool({
name: 'cordis_try',
name: 'cordis_mount',
description:
'Try a temporary Cordis Plugin in the current DSH process. '
'Mount a temporary Cordis Plugin in the current DSH process. '
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
+ 'It remains active across later turns until cordis_stop, toolset unload, or DSH restart. '
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
+ 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
@@ -123,7 +123,7 @@ export function apply(ctx: Context, config: Config): void {
+ '— declares dependencies, and cordis activates the plugin only after the '
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
+ 'dependency would not be cleaned up if its provider stops. '
+ 'dependency would not be cleaned up if its provider is unmounted. '
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
@@ -140,8 +140,8 @@ export function apply(ctx: Context, config: Config): void {
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider stops. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_stop. '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
@@ -150,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+ 'auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+ 'trailing `next` callback which MUST be called — returning without `next()` '
@@ -191,7 +191,7 @@ export function apply(ctx: Context, config: Config): void {
: `is running (plugin "${value.pluginName}"`
return [{
type: 'text',
text: `Temporary Plugin ${value.id} ${status}; available until stopped or DSH restarts).`,
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`,
}]
},
},
@@ -226,19 +226,19 @@ export function apply(ctx: Context, config: Config): void {
waitingFor: missing,
}
},
presentCall: presentTryCall,
presentCall: presentMountCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_stop',
name: 'cordis_unmount',
description:
'Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
parameters: {
id: {
type: 'string',
required: true,
description: 'The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart.',
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.',
},
},
output: {
@@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config): void {
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was stopped and removed.` }],
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }],
},
async execute(args) {
const mount = mounts.get(args.id)
@@ -261,6 +261,6 @@ export function apply(ctx: Context, config: Config): void {
mounts.delete(args.id)
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentStopCall,
presentCall: presentUnmountCall,
}))
}

View File

@@ -100,13 +100,13 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) {
return ['No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.']
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.']
}
return [...mounts].map(([id, mount]) => {
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until stopped or DSH restarts`
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts`
})
}

View File

@@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber>
// while the old mount still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_stop that Plugin's id `
+ '(find it with cordis_inspect what:"temporary"), then try the new version.',
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id `
+ '(find it with cordis_inspect what:"temporary"), then mount the new version.',
)
}
throw error instanceof Error ? error : new Error(message)

View File

@@ -25,28 +25,28 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene
}
/**
* The `cordis_try` call card: an execute carrying the temporary-plugin code as raw input.
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentTryCall(args: { code: string }): GenericCallView {
export function presentMountCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Try temporary Cordis Plugin',
title: 'Mount temporary Cordis Plugin',
rawInput: { code: args.code },
}
}
/**
* The `cordis_stop` call card: a delete, titled with the temporary-plugin id.
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentStopCall(args: { id: string }): GenericCallView {
export function presentUnmountCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Stop temporary Cordis Plugin ${args.id}`,
title: `Unmount temporary Cordis Plugin ${args.id}`,
}
}

View File

@@ -1,5 +1,5 @@
/**
* The `node:vm` sandbox `cordis_try` code evaluates in: a fresh realm whose globals are a
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
@@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void {
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when stopped.'
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
@@ -87,7 +87,7 @@ function nodeApiTraps(): Record<string, () => never> {
}
/**
* Build the vm context one `cordis_try` call evaluates in: the tagged
* Build the vm context one `cordis_mount` call evaluates in: the tagged
* console, the `harness` registration helpers, the encoding primitives, the
* Node-API traps, and the dual-realm `instanceof` patch, already
* `createContext`-ed.

View File

@@ -11,10 +11,10 @@ import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text }
describe('cross-mount provide/inject', () => {
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(text(provider)).toContain('is running')
const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('is running')
@@ -27,39 +27,39 @@ describe('cross-mount provide/inject', () => {
it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
const ctx = await setup()
const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('is pending')
expect(text(consumer)).toContain('missing services: greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter')
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
})
it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
expect(ctx.tools.get('greet')).toBeDefined()
const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
expect(ctx.tools.get('greet')).toBeUndefined()
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until stopped or DSH restarts')
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts')
})
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-3
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]')
@@ -67,8 +67,8 @@ describe('cross-mount provide/inject', () => {
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
const duplicate = await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(duplicate.isError).toBe(true)
expect(text(duplicate)).toContain('has been registered')
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
@@ -78,11 +78,11 @@ describe('cross-mount provide/inject', () => {
it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until stopped or DSH restarts')
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts')
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
@@ -93,7 +93,7 @@ describe('cross-mount provide/inject', () => {
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_try', {
const provider = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'answer-provider',
@@ -106,7 +106,7 @@ describe('cross-mount provide/inject', () => {
})
expect(provider.isError).toBe(false)
const consumer = await call(ctx, 'cordis_try', {
const consumer = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'answer-consumer',
@@ -132,9 +132,9 @@ describe('cross-mount provide/inject', () => {
it('unmounting the consumer leaves the provider and its service intact', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_stop', { id: 'dyn-2' })
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
expect(ctx.tools.get('greet')).toBeUndefined()
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))

View File

@@ -27,8 +27,8 @@ describe('cordis_inspect', () => {
expect(report).toContain('- tools (provided by ToolRegistry)')
expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- cordis_try')
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.')
expect(report).toContain('- cordis_mount')
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.')
})
it('limits the report to one section via `what`', async () => {
@@ -42,10 +42,10 @@ describe('cordis_inspect', () => {
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
const report = text(await call(ctx, 'cordis_inspect', {}))
expect(report).toContain('## Temporary Plugins')
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts')
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts')
expect(report).toContain('- change-logger [active]')
})

View File

@@ -40,9 +40,9 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
describe('cordis tools through the agent loop', () => {
it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'cordis_try', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
toolCallResponse('call-3', 'cordis_stop', { id: 'dyn-1' }),
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
textResponse('Done.'),
])
const ctx = await harness(adapter)
@@ -53,7 +53,7 @@ describe('cordis tools through the agent loop', () => {
const log = agent.session.events
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['cordis_try', 'reverse_text', 'cordis_stop'])
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
const results = log.filter(event => event.type === 'tool/result')
expect(results.map(event => event.data.isError)).toEqual([false, false, false])
@@ -67,22 +67,22 @@ describe('cordis tools through the agent loop', () => {
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('keeps a temporary Plugin across turns, stops it, and does not restore it in a new runtime', async () => {
it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => {
const adapter = new MockAdapter([
toolCallResponse('try-1', 'cordis_try', { code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn one complete.'),
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }),
toolCallResponse('stop-1', 'cordis_stop', { id: 'dyn-1' }),
toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }),
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn two complete.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'Try the marker and inspect it.' }])
agent.followup([{ type: 'text', text: 'Mount the marker and inspect it.' }])
await waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, stop it, then inspect again.' }])
agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }])
await waitForIdle(ctx, agent)
const resultText = new Map(
@@ -92,7 +92,7 @@ describe('cordis tools through the agent loop', () => {
)
expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('stop-1'))).toBe('Temporary Plugin dyn-1 was stopped and removed.')
expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.')
const restarted = await setup()

View File

@@ -5,7 +5,7 @@ import { syntaxErrorContext } from '../src/sandbox.ts'
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_try` success/failure family: real plugins land on a genuine
* The `cordis_mount` success/failure family: real plugins land on a genuine
* cordis fiber tree, their registrations are observable through the real
* registry/event bus, and every rejection path teaches the fix.
*/
@@ -14,7 +14,7 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_try', () => {
describe('cordis_mount', () => {
it.each([
[42, 'options must be an object'],
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
@@ -47,9 +47,9 @@ describe('cordis_try', () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_try', { code: LISTENER_CODE })
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_try success')
if (result.isError) throw new Error('expected cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'change-logger',
@@ -57,7 +57,7 @@ describe('cordis_try', () => {
provides: [],
waitingFor: [],
})
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until stopped or DSH restarts).')
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
ctx.tools.register(dummyTool('trigger_a'))
@@ -66,16 +66,16 @@ describe('cordis_try', () => {
it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
const ctx = await setup()
const anonymous = await call(ctx, 'cordis_try', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
expect(anonymous.isError).toBe(false)
expect(text(anonymous)).toContain('plugin "<anonymous>"')
const named = await call(ctx, 'cordis_try', { code: 'return function watcher(ctx) {}' })
const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
expect(text(named)).toContain('plugin "watcher"')
})
it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(result.isError).toBe(false)
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
@@ -89,14 +89,14 @@ describe('cordis_try', () => {
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
// VM-realm objects fail the session prototype-identity check; normalize them into host JSON.
const ctx = await setup()
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
})
it('projects presentation metadata from a dynamic canonical value', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'meta-return',
@@ -136,7 +136,7 @@ describe('cordis_try', () => {
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'bad-return',
@@ -162,7 +162,7 @@ describe('cordis_try', () => {
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'huge-return',
@@ -189,7 +189,7 @@ describe('cordis_try', () => {
// These common JSON-Schema spellings each have one DSL meaning, so normalize rather than
// consume another model turn with a rejection.
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'json-schema-tool',
@@ -245,7 +245,7 @@ describe('cordis_try', () => {
// On an object PROPERTY, a JSON-Schema-style `required` array names the
// required children — the nested unwrap converts it just like the top level.
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'nested-json-schema',
@@ -276,7 +276,7 @@ describe('cordis_try', () => {
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'unified-schema',
@@ -324,7 +324,7 @@ describe('cordis_try', () => {
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
const ctx = await setup()
const depth = 5_000
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'deep-unified-schema',
@@ -377,7 +377,7 @@ describe('cordis_try', () => {
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-unified-schema',
@@ -467,7 +467,7 @@ describe('cordis_try', () => {
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'bad-schema',
@@ -508,7 +508,7 @@ describe('cordis_try', () => {
],
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'circular-schema',
@@ -531,7 +531,7 @@ describe('cordis_try', () => {
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'proto-schema',
@@ -566,7 +566,7 @@ describe('cordis_try', () => {
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'nested-schema',
@@ -593,7 +593,7 @@ describe('cordis_try', () => {
it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-register',
@@ -618,7 +618,7 @@ describe('cordis_try', () => {
it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-register-get',
@@ -636,13 +636,13 @@ describe('cordis_try', () => {
it('passes non-register registry members through the guard with correct binding', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'schema-reader',
inject: ['tools'],
apply(ctx) {
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_try'))
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
},
}
`,
@@ -653,11 +653,11 @@ describe('cordis_try', () => {
it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected pending cordis_try success')
if (result.isError) throw new Error('expected pending cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'waiter',
@@ -665,15 +665,15 @@ describe('cordis_try', () => {
provides: [],
waitingFor: ['no-such-service'],
})
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until stopped or DSH restarts).')
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).')
// Unmounting a pending mount works like any other.
const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
})
it('rejects code that throws, leaving nothing mounted', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: 'throw new Error(\'boom in sandbox\')' })
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom in sandbox')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
@@ -681,30 +681,30 @@ describe('cordis_try', () => {
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
const ctx = await setup()
const primitive = await call(ctx, 'cordis_try', { code: 'throw \'plain-string-throw\'' })
const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
expect(primitive.isError).toBe(true)
expect(text(primitive)).toContain('plain-string-throw')
const nullish = await call(ctx, 'cordis_try', { code: 'throw null' })
const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
expect(nullish.isError).toBe(true)
})
it('rejects code that does not return a plugin', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: 'return 42' })
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('must `return` a Plugin')
})
it('answers a missing return with the two valid plugin forms', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: 'const plugin = (ctx) => {}' })
const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('did you forget `return`?')
})
it('disposes a plugin whose apply throws, and reports the error', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
})
expect(result.isError).toBe(true)
@@ -714,14 +714,14 @@ describe('cordis_try', () => {
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'usurper',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'cordis_try',
name: 'cordis_mount',
description: 'dup',
parameters: {},
${CONTENT_OUTPUT_CODE}
@@ -733,15 +733,15 @@ describe('cordis_try', () => {
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('already registered')
expect(text(result)).toContain('first cordis_stop')
// The original cordis_try still dispatches — the failed fiber is gone.
const retry = await call(ctx, 'cordis_try', { code: LISTENER_CODE })
expect(text(result)).toContain('first cordis_unmount')
// The original cordis_mount still dispatches — the failed fiber is gone.
const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(retry.isError).toBe(false)
})
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
globalThis.__cordis_tool_leak = 'leaked'
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
@@ -758,7 +758,7 @@ describe('cordis_try', () => {
['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'],
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: `${invocation}\nreturn (ctx) => {}` })
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
expect(result.isError).toBe(true)
expect(text(result)).toContain(trapMessage)
expect(text(result)).toContain(redirect)
@@ -768,7 +768,7 @@ describe('cordis_try', () => {
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'ticker',
@@ -789,7 +789,7 @@ describe('cordis_try', () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
console.warn('warned')
console.error('errored')
@@ -807,7 +807,7 @@ describe('cordis_try', () => {
it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'ts\' as const, apply(ctx) {} }',
})
expect(result.isError).toBe(true)
@@ -819,7 +819,7 @@ describe('cordis_try', () => {
// The canonical model mistake: closing the returned object with `});` as
// if it were a callback argument. The word "as" in a STRING elsewhere must
// not trigger the TypeScript hint — the heuristic reads the failing line.
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});',
})
expect(result.isError).toBe(true)
@@ -842,7 +842,7 @@ describe('cordis_try', () => {
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', { code: 'throw new SyntaxError(\'user-crafted\')' })
const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('failed to parse')
expect(text(result)).toContain('user-crafted')
@@ -850,7 +850,7 @@ describe('cordis_try', () => {
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
const ctx = await setup({ vmTimeoutMs: 50 })
const result = await call(ctx, 'cordis_try', { code: 'while (true) {}' })
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/timed? ?out/i)
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
@@ -861,7 +861,7 @@ describe('cordis_try', () => {
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
// false.
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'probe-instanceof',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { presentInspectCall, presentTryCall, presentStopCall } from '../src/present.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
import { setup } from './helpers.ts'
/**
@@ -18,17 +18,17 @@ describe('presenters', () => {
})
})
it('cordis_try renders a generic execute card carrying the code as raw input', () => {
expect(presentTryCall({ code: 'return (ctx) => {}' })).toEqual({
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
card: 'generic',
kind: 'execute',
title: 'Try temporary Cordis Plugin',
title: 'Mount temporary Cordis Plugin',
rawInput: { code: 'return (ctx) => {}' },
})
})
it('cordis_stop renders a generic delete card titled with the id', () => {
expect(presentStopCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Stop temporary Cordis Plugin dyn-1' })
it('cordis_unmount renders a generic delete card titled with the id', () => {
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount temporary Cordis Plugin dyn-1' })
})
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
@@ -41,9 +41,9 @@ describe('presenters', () => {
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
title: 'Inspect cordis runtime: api: tools',
})
expect(ctx.tools.get('cordis_try')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Stop temporary Cordis Plugin dyn-2' })
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' })
// Soft validation: presenter args that fail the schema render as no card, never a throw.
expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 42 })).toBeUndefined()
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
})
})

View File

@@ -10,7 +10,7 @@ import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
})
expect(result.isError).toBe(true)
@@ -41,7 +41,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'root-bypass',
@@ -66,7 +66,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('rejects assignment to the façade rather than silently dropping it', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
})
expect(result.isError).toBe(true)
@@ -78,7 +78,7 @@ describe('sandbox context façade — escape surface is closed', () => {
// `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return
// guards reject that Context before the registration lands.
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'svc-ctx-escape',
@@ -108,7 +108,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'host-async-svc',
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
})
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'async-consumer',
@@ -135,7 +135,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'introspector',
@@ -157,7 +157,7 @@ describe('sandbox context façade — inject gate on services', () => {
// mount does not declare it — reaching it would let the mount depend on a
// provider cordis does not know about, so it is refused.
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
})
expect(result.isError).toBe(true)
@@ -167,7 +167,7 @@ describe('sandbox context façade — inject gate on services', () => {
it('denies an undeclared live service reached through ctx.get too', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
})
expect(result.isError).toBe(true)
@@ -176,7 +176,7 @@ describe('sandbox context façade — inject gate on services', () => {
it('allows a service the mount DID declare in inject', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_try', {
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'declared',
@@ -193,10 +193,10 @@ describe('sandbox context façade — inject gate on services', () => {
// Without declared inject, Cordis cannot park the consumer when its provider unmounts. The
// façade refuses access up front instead of leaving a zombie tool.
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
})
const undeclared = await call(ctx, 'cordis_try', {
const undeclared = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'sloppy-consumer',
@@ -229,7 +229,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
// returns the same name/description/parameters view as schemas(), with no execute.
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'reporter',
@@ -241,7 +241,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const view = ctx.tools.get('cordis_try')
const view = ctx.tools.get('cordis_mount')
return [{ type: 'text', text: JSON.stringify({
hasExecute: 'execute' in view,
hasPresentCall: 'presentCall' in view,
@@ -259,13 +259,13 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
expect(shape.hasExecute).toBe(false)
expect(shape.hasPresentCall).toBe(false)
expect(shape.name).toBe('cordis_try')
expect(shape.name).toBe('cordis_mount')
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
})
it('ctx.tools.get returns undefined for an unknown tool', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', {
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'unknown-probe',

View File

@@ -30,8 +30,8 @@ describe('tool registration', () => {
it('registers the three cordis tools with the documented schemas', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_try', 'cordis_stop']))
expect(names).not.toEqual(expect.arrayContaining(['cordis_mount', 'cordis_unmount']))
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events'])

View File

@@ -6,7 +6,7 @@ import * as tool from '../src/index.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Disposal semantics: `cordis_stop` reaches quiescence before returning,
* Disposal semantics: `cordis_unmount` reaches quiescence before returning,
* and disposing the tool-cordis fiber itself (the HMR path) cascades over the
* whole dynamic subtree through the ordinary parent→child fiber lifecycle.
*/
@@ -15,20 +15,20 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_stop', () => {
describe('cordis_unmount', () => {
it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
ctx.tools.register(dummyTool('trigger_before'))
expect(log).toHaveBeenCalledTimes(1)
const result = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_stop success')
if (result.isError) throw new Error('expected cordis_unmount success')
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
expect(text(result)).toBe('Temporary Plugin dyn-1 was stopped and removed.')
expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
// Immediately after the awaited unmount, the listener must be gone — no
// grace period, no eventual consistency.
@@ -39,22 +39,22 @@ describe('cordis_stop', () => {
it('unregisters a self-made tool on unmount', async () => {
const ctx = await setup()
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('rejects an unknown id, and a second unmount of the same id', async () => {
const ctx = await setup()
const unknown = await call(ctx, 'cordis_stop', { id: 'dyn-99' })
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
expect(unknown.isError).toBe(true)
expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"')
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
const again = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(again.isError).toBe(true)
})
})
@@ -67,8 +67,8 @@ describe('HMR safety', () => {
const fiber = await ctx.plugin(tool)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await fiber.dispose()
@@ -76,7 +76,7 @@ describe('HMR safety', () => {
// The whole subtree is gone: the self-made tool, the cordis tools, and the
// mounted listener (no log on a fresh tools/change).
expect(ctx.tools.get('reverse_text')).toBeUndefined()
expect(ctx.tools.get('cordis_try')).toBeUndefined()
expect(ctx.tools.get('cordis_mount')).toBeUndefined()
const calls = log.mock.calls.length
ctx.tools.register(dummyTool('trigger_post_dispose'))
expect(log).toHaveBeenCalledTimes(calls)

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_stop', 'cordis_try', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -18,10 +18,10 @@ buffer
5| <blank>
6| "▌ "
style 0-0 fg=yellow
7| "▌ ◌ Try temporary Cordis Plugin "
7| "▌ ◌ Mount temporary Cordis Plugin "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-30 bold
style 3-32 bold
8| "▌ { "
style 0-0 fg=yellow
9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
@@ -33,10 +33,10 @@ buffer
12| "▌ "
style 0-0 fg=yellow
13| <blank>
14| "▌ ◌ Stop temporary Cordis Plugin dyn-1 "
14| "▌ ◌ Unmount temporary Cordis Plugin dyn-1 "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-37 bold
style 3-40 bold
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "

View File

@@ -414,10 +414,10 @@ describe('TUI terminal-state snapshots', () => {
{ id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } },
{
id: 'cordis-2',
name: 'cordis_try',
name: 'cordis_mount',
arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" },
},
{ id: 'cordis-3', name: 'cordis_stop', arguments: { id: 'dyn-1' } },
{ id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } },
]
await renderAfter(harness, () => { appendToolCalls(harness.session, calls) })
await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true })