Skip to content

Commit d62cd7a

Browse files
oratisclaude
andauthored
feat(core,cli): M5.2 — plugin live wire-up (discover → spawn → register) (#20)
Closes the "M5.1: 65% — needs live registry wireup" line item. Core (@deepcode/core): · packages/core/src/plugins/wireup.ts (NEW, ~140 lines) - wirePlugins({ home, hooks, capabilities, disabled }) — orchestrates discoverPlugins → spawnAllPlugins → mergeHooks(declared) for each successfully started plugin. - WireResult exposes plugins (with contributed hook events), hash mismatches, spawn failures, and a shutdown() to kill subprocesses. - hasInstalledPlugins() probe helper. · packages/core/src/hooks/dispatcher.ts - HookDispatcher.mergeHooks(extra: Hooks) — appends matchers under each event so plugins can extend dispatch at runtime. - `hooks` field changed from readonly to private mutable. · packages/core/src/plugins/runtime/subprocess.ts - Exposes `get plugin()` and `get isAlive()` accessors so wireup can map subprocess back to its source InstalledPlugin without reaching into private opts. · packages/core/src/agent.ts - ToolContext.sessionDir is now derived from `${sessions.root}/${sessionId}` so TodoWrite persists to the session-scoped dir and /todos can read it back. CLI (deepcode-cli): · apps/cli/src/repl.ts + headless.ts - Both bootstraps now call wirePlugins() after HookDispatcher construction, build a capability bridge from BashTool/ReadTool/WriteTool/ WebFetchTool, and shutdown() the wire on exit. - SessionContext carries wiredPlugins + pluginWarnings so /plugins can render them. · apps/cli/src/commands.ts - NEW /plugins slash command. Lists active plugins with their version and contributed hook events; surfaces hash drift + spawn failure warnings. - /todos rewritten — now actually reads `<sessionsRoot>/<sessionId>/todos.json` via readTodos() helper. Tests: core 308 → 317 (+9 wireup tests); cli 43 → 47 (+4 /plugins+/todos); total 360 → 364 passing. (`pnpm -r test`). BEHAVIOR_PARITY: /todos and /plugins move from 🔄 to ✅. Acknowledged gaps (M5.2-ext+): · OS-level sandbox wrapping of plugin subprocess (M5.1-ext) · gh:user/repo + npm install paths (M5.2-rest) · Marketplace index + ed25519 signatures (M5.2-rest) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31a05be commit d62cd7a

12 files changed

Lines changed: 667 additions & 6 deletions

File tree

apps/cli/src/commands.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,48 @@ describe('built-in command behavior', () => {
168168
const out = await reg.match('/resume')!.cmd.run([], ctx);
169169
expect(out.join('\n')).toMatch(/Recent sessions/);
170170
});
171+
172+
it('/plugins shows empty + install hint when none wired', async () => {
173+
const reg = new CommandRegistry();
174+
const out = await reg.match('/plugins')!.cmd.run([], makeContext());
175+
expect(out.join('\n')).toMatch(/No plugins installed/);
176+
expect(out.join('\n')).toMatch(/deepcode plugin install/);
177+
});
178+
179+
it('/plugins lists wired plugins + contributed hook events', async () => {
180+
const reg = new CommandRegistry();
181+
const ctx = makeContext({
182+
wiredPlugins: [
183+
{ name: 'demo', version: '1.0.0', contributedHookEvents: ['PostToolUse'] },
184+
{ name: 'silent', version: '0.1.0', contributedHookEvents: [] },
185+
],
186+
});
187+
const out = await reg.match('/plugins')!.cmd.run([], ctx);
188+
const joined = out.join('\n');
189+
expect(joined).toMatch(/Active plugins \(2\)/);
190+
expect(joined).toMatch(/demo@1\.0\.0/);
191+
expect(joined).toMatch(/PostToolUse/);
192+
expect(joined).toMatch(/silent@0\.1\.0/);
193+
});
194+
195+
it('/plugins surfaces warnings (hash drift / spawn failure)', async () => {
196+
const reg = new CommandRegistry();
197+
const ctx = makeContext({
198+
pluginWarnings: ['drifty: hash drift (was abc, now def)', 'bad: failed to start'],
199+
});
200+
const out = await reg.match('/plugins')!.cmd.run([], ctx);
201+
const joined = out.join('\n');
202+
expect(joined).toMatch(/Warnings/);
203+
expect(joined).toMatch(/hash drift/);
204+
expect(joined).toMatch(/failed to start/);
205+
});
206+
207+
it('/todos returns "No active todos" when none stored', async () => {
208+
const reg = new CommandRegistry();
209+
const sm = new SessionManager({ root: sessRoot });
210+
const meta = await sm.create('/foo');
211+
const ctx = makeContext({ sessions: sm, sessionId: meta.id });
212+
const out = await reg.match('/todos')!.cmd.run([], ctx);
213+
expect(out.join('\n')).toMatch(/No active todos/);
214+
});
171215
});

apps/cli/src/commands.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export interface SessionContext {
2727
mcpServers?: McpClientHandle[];
2828
/** MCP servers that failed to connect on startup (M3c). */
2929
mcpErrors?: Array<{ serverName: string; error: string }>;
30+
/** Plugins that successfully wired up (M5.2). */
31+
wiredPlugins?: Array<{
32+
name: string;
33+
version: string;
34+
contributedHookEvents: string[];
35+
}>;
36+
/** Plugin discover/wire warnings (hash drift, spawn failure). */
37+
pluginWarnings?: string[];
3038
}
3139

3240
export interface SlashCommand {
@@ -257,9 +265,57 @@ export const McpCommand: SlashCommand = {
257265

258266
export const TodosCommand: SlashCommand = {
259267
name: '/todos',
260-
description: 'Show active TODO list (M3 wires TodoWrite tool).',
261-
run() {
262-
return ['No active todos — TodoWrite tool ships in M3.'];
268+
description: 'Show active TODO list (TodoWrite tool — M3c-rest).',
269+
async run(_args, ctx) {
270+
try {
271+
const { readTodos } = await import('@deepcode/core');
272+
const path = await import('node:path');
273+
const dir = path.join(ctx.sessions.root, ctx.sessionId);
274+
const todos = await readTodos(dir);
275+
if (todos.length === 0) return ['No active todos.'];
276+
const lines = [`Todos (${todos.length}):`];
277+
for (const t of todos) {
278+
const marker =
279+
t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '●' : '○';
280+
const text = t.status === 'in_progress' ? t.activeForm : t.content;
281+
lines.push(` ${marker} ${text}`);
282+
}
283+
return lines;
284+
} catch (err) {
285+
return [`(Error reading todos: ${(err as Error).message})`];
286+
}
287+
},
288+
};
289+
290+
export const PluginsCommand: SlashCommand = {
291+
name: '/plugins',
292+
description: 'List wired plugins and what they contribute.',
293+
run(_args, ctx) {
294+
const plugins = ctx.wiredPlugins ?? [];
295+
const warnings = ctx.pluginWarnings ?? [];
296+
const lines: string[] = [];
297+
if (plugins.length === 0 && warnings.length === 0) {
298+
lines.push('No plugins installed.');
299+
lines.push('');
300+
lines.push('Install with: deepcode plugin install <path>');
301+
lines.push('(M5 = manifest + hash pin; M5.1 = subprocess + RPC; M5.2 = live wire-up.)');
302+
return lines;
303+
}
304+
if (plugins.length > 0) {
305+
lines.push(`Active plugins (${plugins.length}):`);
306+
for (const p of plugins) {
307+
const events = p.contributedHookEvents.length
308+
? ` hooks: ${p.contributedHookEvents.join(', ')}`
309+
: '';
310+
lines.push(` ● ${p.name}@${p.version}${events}`);
311+
}
312+
}
313+
if (warnings.length > 0) {
314+
if (lines.length > 0) lines.push('');
315+
lines.push(`Warnings:`);
316+
for (const w of warnings) lines.push(` ⚠ ${w}`);
317+
}
318+
return lines;
263319
},
264320
};
265321

@@ -279,6 +335,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
279335
InitCommand,
280336
McpCommand,
281337
TodosCommand,
338+
PluginsCommand,
282339
];
283340

284341
// ──────────────────────────────────────────────────────────────────────────

apps/cli/src/headless.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515
// 5 aborted by signal (SIGINT / SIGTERM)
1616

1717
import {
18+
BashTool,
1819
CredentialsStore,
1920
DeepSeekProvider,
2021
EFFORT_PARAMS,
2122
HookDispatcher,
23+
ReadTool,
2224
SessionManager,
2325
ToolRegistry,
26+
WebFetchTool,
27+
WriteTool,
2428
applyStyle,
2529
buildSkillsDescriptionBlock,
2630
closeAllMcpServers,
@@ -33,11 +37,13 @@ import {
3337
makeSkillTool,
3438
resolveCredentials,
3539
runAgent,
40+
wirePlugins,
3641
type AgentEvent,
3742
type DeepCodeSettings,
3843
type Effort,
3944
type McpClientHandle,
4045
type Mode,
46+
type WireResult,
4147
} from '@deepcode/core';
4248
import type { Writable } from 'node:stream';
4349

@@ -163,6 +169,21 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
163169
allowedHttpHookUrls: settings.allowedHttpHookUrls,
164170
});
165171

172+
// M5.2: wire installed plugins. We pipe their startup log to stderr to keep
173+
// stdout reserved for the headless output payload.
174+
let pluginsWire: WireResult | null = null;
175+
try {
176+
pluginsWire = await wirePlugins({
177+
home: opts.home,
178+
disabled: settings.disabledPlugins,
179+
hooks,
180+
capabilities: buildPluginCapabilitiesHeadless(cwd),
181+
log: (s) => errOutput.write(s + '\n'),
182+
});
183+
} catch (err) {
184+
errOutput.write(`Plugin wire-up failed: ${(err as Error).message}\n`);
185+
}
186+
166187
const sessions = new SessionManager();
167188
const session = await sessions.create(cwd, { model });
168189

@@ -260,11 +281,41 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
260281
process.off('SIGINT', sigintHandler);
261282
process.off('SIGTERM', sigintHandler);
262283
if (mcpServers.length > 0) await closeAllMcpServers(mcpServers);
284+
if (pluginsWire) await pluginsWire.shutdown();
263285
}
264286

265287
return exitCode;
266288
}
267289

290+
function buildPluginCapabilitiesHeadless(cwd: string) {
291+
const ctx = { cwd };
292+
return {
293+
fs_read: async (path: string) => {
294+
const r = await ReadTool.execute({ file_path: path }, ctx);
295+
if (r.isError) throw new Error(r.content);
296+
return r.content;
297+
},
298+
fs_write: async (path: string, content: string) => {
299+
const r = await WriteTool.execute({ file_path: path, content }, ctx);
300+
if (r.isError) throw new Error(r.content);
301+
},
302+
bash: async (cmd: string) => {
303+
const r = await BashTool.execute({ command: cmd }, ctx);
304+
const d = (r.data ?? {}) as { stderr?: string; exitCode?: number };
305+
return {
306+
stdout: r.content ?? '',
307+
stderr: d.stderr ?? '',
308+
exitCode: d.exitCode ?? (r.isError ? 1 : 0),
309+
};
310+
},
311+
fetch: async (url: string) => {
312+
const r = await WebFetchTool.execute({ url }, ctx);
313+
if (r.isError) throw new Error(r.content);
314+
return r.content;
315+
},
316+
};
317+
}
318+
268319
function formatEventText(out: Writable, e: AgentEvent): void {
269320
switch (e.type) {
270321
case 'text_delta':

apps/cli/src/repl.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
// Spec: docs/DEVELOPMENT_PLAN.md §5
33

44
import {
5+
BashTool,
56
CredentialsStore,
67
DeepSeekProvider,
78
EFFORT_PARAMS,
89
HookDispatcher,
10+
ReadTool,
911
SessionManager,
1012
ToolRegistry,
13+
WebFetchTool,
14+
WriteTool,
1115
applyStyle,
1216
buildSkillsDescriptionBlock,
1317
closeAllMcpServers,
@@ -20,12 +24,14 @@ import {
2024
makeSkillTool,
2125
resolveCredentials,
2226
runAgent,
27+
wirePlugins,
2328
type DeepCodeSettings,
2429
type Effort,
2530
type McpClientHandle,
2631
type Mode,
2732
type AgentEvent,
2833
type StoredMessage,
34+
type WireResult,
2935
} from '@deepcode/core';
3036
import { createInterface } from 'node:readline/promises';
3137
import type { Readable, Writable } from 'node:stream';
@@ -182,6 +188,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
182188
allowedHttpHookUrls: settings.allowedHttpHookUrls,
183189
});
184190

191+
// M5.2: wire installed plugins (discover + spawn + merge contributed hooks)
192+
let pluginsWire: WireResult | null = null;
193+
try {
194+
pluginsWire = await wirePlugins({
195+
home: opts.home,
196+
disabled: settings.disabledPlugins,
197+
hooks,
198+
capabilities: buildPluginCapabilities(cwd),
199+
log: (s) => output.write(s + '\n'),
200+
});
201+
} catch (err) {
202+
output.write(` ⊞ Plugins: wire-up failed — ${(err as Error).message}\n`);
203+
}
204+
185205
let history: StoredMessage[] = [];
186206
const ctx: SessionContext = {
187207
cwd,
@@ -195,6 +215,15 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
195215
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },
196216
mcpServers,
197217
mcpErrors,
218+
wiredPlugins: pluginsWire?.plugins.map((p) => ({
219+
name: p.plugin.manifest.name,
220+
version: p.plugin.manifest.version,
221+
contributedHookEvents: p.contributedHookEvents,
222+
})),
223+
pluginWarnings: [
224+
...(pluginsWire?.hashMismatches ?? []),
225+
...(pluginsWire?.spawnFailures.map((n) => `${n}: failed to start`) ?? []),
226+
],
198227
};
199228

200229
output.write(`\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`);
@@ -282,6 +311,8 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
282311
if (mcpServers.length > 0) {
283312
await closeAllMcpServers(mcpServers);
284313
}
314+
// Shut down plugin subprocesses
315+
if (pluginsWire) await pluginsWire.shutdown();
285316
return 0;
286317
}
287318

@@ -321,6 +352,53 @@ function truncate(s: string, n: number): string {
321352
return s.length > n ? s.slice(0, n) + '…' : s;
322353
}
323354

355+
/**
356+
* Build the capability bridge passed to plugin subprocesses (M5.2).
357+
*
358+
* Each capability invokes the host's existing tool implementation — which
359+
* means plugin calls flow through the SAME read/write/exec gates as the
360+
* agent. (mode + permissions + sandbox come from the ToolContext we pass in.)
361+
*
362+
* NOTE: this bridge does NOT carry mode/permissions yet — that's M5.2-ext.
363+
* Today the plugin's `bash` calls are unsandboxed because we don't have a
364+
* sandboxConfig in ctx here. Callers wanting hardening should set
365+
* settings.sandbox.enabled and pass sandboxConfig in a later iteration.
366+
*/
367+
function buildPluginCapabilities(cwd: string): {
368+
fs_read: (path: string) => Promise<string>;
369+
fs_write: (path: string, content: string) => Promise<void>;
370+
bash: (cmd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
371+
fetch: (url: string, opts?: { method?: string; body?: string }) => Promise<string>;
372+
} {
373+
const ctx = { cwd };
374+
return {
375+
fs_read: async (path: string) => {
376+
const r = await ReadTool.execute({ file_path: path }, ctx);
377+
if (r.isError) throw new Error(r.content);
378+
return r.content;
379+
},
380+
fs_write: async (path: string, content: string) => {
381+
const r = await WriteTool.execute({ file_path: path, content }, ctx);
382+
if (r.isError) throw new Error(r.content);
383+
},
384+
bash: async (cmd: string) => {
385+
const r = await BashTool.execute({ command: cmd }, ctx);
386+
const d = (r.data ?? {}) as { stderr?: string; exitCode?: number };
387+
return {
388+
stdout: r.content ?? '',
389+
stderr: d.stderr ?? '',
390+
exitCode: d.exitCode ?? (r.isError ? 1 : 0),
391+
};
392+
},
393+
fetch: async (url: string, fopts?: { method?: string; body?: string }) => {
394+
void fopts; // method/body deferred — WebFetch is GET-only
395+
const r = await WebFetchTool.execute({ url }, ctx);
396+
if (r.isError) throw new Error(r.content);
397+
return r.content;
398+
},
399+
};
400+
}
401+
324402
/**
325403
* Find the bundled built-in skills directory.
326404
* In dev: <repo>/packages/core/skills/.

docs/BEHAVIOR_PARITY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
2424
| `/init` || ✓ (stub) | 🔄 — multi-phase interactive flow deferred to M3c-ext |
2525
| `/mcp` ||||
2626
| `/add-dir` || ✓ (records intent) | 🟡 — M3 will enforce |
27-
| `/todos` || ✓ (stub) | 🔄 — wired with TodoWrite tool (M3+) |
27+
| `/todos` ||| ✅ — reads `<sessionDir>/todos.json` written by TodoWrite tool |
28+
| `/plugins` ||| ✅ — lists wired plugins + contributed hook events + warnings (M5.2) |
2829
| `/compact` || ✓ auto-trigger | 🟡 — manual `/compact` slash command not exposed yet (auto works via agent loop) |
2930
| `/btw` ||| 🔄 |
3031
| `/recap` ||| 🔄 |

packages/core/src/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
102102
cwd: opts.cwd,
103103
signal: opts.signal,
104104
sandboxConfig: opts.sandboxConfig,
105+
sessionDir: opts.session
106+
? `${opts.session.manager.root}/${opts.session.id}`
107+
: undefined,
105108
};
106109
const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 };
107110
let turnsUsed = 0;

0 commit comments

Comments
 (0)