Rename Cordis tools for temporary plugins

Clarify process-local lifecycle semantics and refresh generated documentation, demos, and snapshots.
This commit is contained in:
NI0317
2026-07-27 16:57:26 +08:00
parent 5be0118784
commit b4a1304489
73 changed files with 1807 additions and 1002 deletions

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 mount if the provider is later unmounted.',
+ 'so cordis parks this temporary Plugin if the provider later stops.',
)
}
throw new Error(

View File

@@ -1,6 +1,6 @@
/**
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin
* under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* 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,
* 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, presentMountCall, presentUnmountCall } from './present.ts'
import { presentInspectCall, presentStopCall, presentTryCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
@@ -40,8 +40,8 @@ export const Config: z<Config> = z.object({
type ResolvedConfig = Required<Config>
/**
* Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic`
* group fiber every dynamic mount hangs under.
* Register the three cordis tools and own every temporary plugin under one
* `cordis-dynamic` group fiber.
* @param ctx - the plugin context (`tools` injected).
* @param config - the schemastery-resolved {@link Config}.
*/
@@ -56,19 +56,21 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'cordis_inspect',
description:
'Inspect the live cordis runtime that is running THIS agent. Read-only. '
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
+ '`plugins` (all live plugin fibers with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+ '`temporary` (only temporary Plugins created by cordis_try: 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). '
+ 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` '
+ '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. '
+ '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.',
parameters: {
what: {
type: 'string',
enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
description: 'Limit the report to one section. Omit for all sections.',
},
name: {
@@ -84,19 +86,19 @@ export function apply(ctx: Context, config: Config): void {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
const sections: [heading: string, body: () => string[]][] = [
['services', () => describeServices(ctx)],
['plugins', () => describePlugins(ctx)],
const sections: [key: string, heading: string, body: () => string[]][] = [
['services', 'services', () => describeServices(ctx)],
['plugins', 'plugins', () => describePlugins(ctx)],
// The calling agent's view: scoped/shadowed tools included, restricted
// globals absent — "what you can call", not the global registry.
['tools', () => describeTools(ctx, exec.agent)],
['dynamic', () => describeDynamic(ctx, mounts)],
['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', () => describeEvents(EVENT_API, args.name)],
['tools', 'tools', () => describeTools(ctx, exec.agent)],
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', 'events', () => describeEvents(EVENT_API, args.name)],
]
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve(text)
},
@@ -104,10 +106,15 @@ export function apply(ctx: Context, config: Config): void {
}))
ctx.tools.register(defineTool({
name: 'cordis_mount',
name: 'cordis_try',
description:
'Mount a NEW cordis plugin into the live runtime that is running THIS agent '
+ '(self-modification). `code` runs as the body of an async JavaScript function '
'Try 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 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. '
+ '`code` runs now as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
@@ -116,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 is unmounted. '
+ 'dependency would not be cleaned up if its provider stops. '
+ '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). '
@@ -131,10 +138,10 @@ export function apply(ctx: Context, config: Config): void {
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ '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 is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically on unmount. '
+ 'until the provider exists and returns to pending when the provider stops. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_stop. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
@@ -143,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 on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'auto-cleaned when stopped) — 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()` '
@@ -159,7 +166,7 @@ export function apply(ctx: Context, config: Config): void {
code: {
type: 'string',
required: true,
description: 'Body of an async JS function; must `return` the plugin to mount.',
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
},
},
output: {
@@ -179,12 +186,12 @@ export function apply(ctx: Context, config: Config): void {
},
},
render: (_args, value) => {
const note = value.waitingFor.length > 0
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
: ''
const status = value.waitingFor.length > 0
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
: `is running (plugin "${value.pluginName}"`
return [{
type: 'text',
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
text: `Temporary Plugin ${value.id} ${status}; available until stopped or DSH restarts).`,
}]
},
},
@@ -195,13 +202,13 @@ export function apply(ctx: Context, config: Config): void {
if (!isPlugin(evaluated)) {
if (evaluated === undefined) {
throw new Error(
'mount code returned `undefined` — did you forget `return`?\n'
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
)
}
throw new Error(
'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method',
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
)
}
const fiber = await mountDynamic(group, evaluated)
@@ -219,21 +226,19 @@ export function apply(ctx: Context, config: Config): void {
waitingFor: missing,
}
},
presentCall: presentMountCall,
presentCall: presentTryCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_unmount',
name: 'cordis_stop',
description:
'Dispose a plugin previously mounted with cordis_mount, by id. All its '
+ 'registrations (event listeners, tools, services) are cleaned up through '
+ 'the cordis effect lifecycle. Returns only after disposal has fully '
+ 'completed (quiescence, not just a request to stop).',
'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. '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
parameters: {
id: {
type: 'string',
required: true,
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
description: 'The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart.',
},
},
output: {
@@ -245,17 +250,17 @@ export function apply(ctx: Context, config: Config): void {
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was stopped and removed.` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`)
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
}
await mount.fiber.dispose()
mounts.delete(args.id)
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentUnmountCall,
presentCall: presentStopCall,
}))
}

View File

@@ -1,6 +1,6 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
* original source JSDoc without inflating the default reports.
* @module @deepseek-ai/dsh-tool-cordis/inspect
@@ -63,8 +63,8 @@ export function describeServices(ctx: Context): string[] {
/**
* The `plugins` section: a flat list of every fiber the registry knows, one
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
* mounted more than once repeats — one line per instance). Dynamic mounts are
* listed like any other plugin; their ids live in the `dynamic` section.
* mounted more than once repeats — one line per instance). Temporary plugins are
* listed like any other plugin; their ids live in the `temporary` section.
* @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber.
*/
@@ -91,7 +91,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
}
/**
* The `dynamic` section: one line per mount with id, plugin name, lifecycle
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
* state, the services its subtree provides, and — for a pending mount — the
* services it waits for.
* @param ctx - the runtime the mounts live in.
@@ -99,13 +99,14 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
* @returns one line per mount, or a single placeholder line when none exist.
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
if (mounts.size === 0) {
return ['No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.']
}
return [...mounts].map(([id, mount]) => {
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}`
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`
})
}

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 mount registered, first cordis_unmount that mount's id `
+ '(find it with cordis_inspect what:"dynamic"), then mount the new version.',
`${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.',
)
}
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_mount` call card: an execute carrying the mount code as raw input.
* The `cordis_try` 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 presentMountCall(args: { code: string }): GenericCallView {
export function presentTryCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
title: 'Try temporary Cordis Plugin',
rawInput: { code: args.code },
}
}
/**
* The `cordis_unmount` call card: a delete, titled with the mount id.
* The `cordis_stop` call card: a delete, titled with the temporary-plugin id.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentUnmountCall(args: { id: string }): GenericCallView {
export function presentStopCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Unmount ${args.id}`,
title: `Stop temporary Cordis Plugin ${args.id}`,
}
}

View File

@@ -1,5 +1,5 @@
/**
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
* The `node:vm` sandbox `cordis_try` 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 on unmount.'
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when stopped.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
@@ -80,14 +80,14 @@ function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one `cordis_mount` call evaluates in: the tagged
* Build the vm context one `cordis_try` 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.
@@ -163,14 +163,14 @@ export async function evaluateMountCode(sandbox: object, code: string, id: strin
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }',
)
}
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',