Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,48 @@ describe('built-in command behavior', () => {
const out = await reg.match('/resume')!.cmd.run([], ctx);
expect(out.join('\n')).toMatch(/Recent sessions/);
});

it('/plugins shows empty + install hint when none wired', async () => {
const reg = new CommandRegistry();
const out = await reg.match('/plugins')!.cmd.run([], makeContext());
expect(out.join('\n')).toMatch(/No plugins installed/);
expect(out.join('\n')).toMatch(/deepcode plugin install/);
});

it('/plugins lists wired plugins + contributed hook events', async () => {
const reg = new CommandRegistry();
const ctx = makeContext({
wiredPlugins: [
{ name: 'demo', version: '1.0.0', contributedHookEvents: ['PostToolUse'] },
{ name: 'silent', version: '0.1.0', contributedHookEvents: [] },
],
});
const out = await reg.match('/plugins')!.cmd.run([], ctx);
const joined = out.join('\n');
expect(joined).toMatch(/Active plugins \(2\)/);
expect(joined).toMatch(/demo@1\.0\.0/);
expect(joined).toMatch(/PostToolUse/);
expect(joined).toMatch(/silent@0\.1\.0/);
});

it('/plugins surfaces warnings (hash drift / spawn failure)', async () => {
const reg = new CommandRegistry();
const ctx = makeContext({
pluginWarnings: ['drifty: hash drift (was abc, now def)', 'bad: failed to start'],
});
const out = await reg.match('/plugins')!.cmd.run([], ctx);
const joined = out.join('\n');
expect(joined).toMatch(/Warnings/);
expect(joined).toMatch(/hash drift/);
expect(joined).toMatch(/failed to start/);
});

it('/todos returns "No active todos" when none stored', async () => {
const reg = new CommandRegistry();
const sm = new SessionManager({ root: sessRoot });
const meta = await sm.create('/foo');
const ctx = makeContext({ sessions: sm, sessionId: meta.id });
const out = await reg.match('/todos')!.cmd.run([], ctx);
expect(out.join('\n')).toMatch(/No active todos/);
});
});
63 changes: 60 additions & 3 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface SessionContext {
mcpServers?: McpClientHandle[];
/** MCP servers that failed to connect on startup (M3c). */
mcpErrors?: Array<{ serverName: string; error: string }>;
/** Plugins that successfully wired up (M5.2). */
wiredPlugins?: Array<{
name: string;
version: string;
contributedHookEvents: string[];
}>;
/** Plugin discover/wire warnings (hash drift, spawn failure). */
pluginWarnings?: string[];
}

export interface SlashCommand {
Expand Down Expand Up @@ -257,9 +265,57 @@ export const McpCommand: SlashCommand = {

export const TodosCommand: SlashCommand = {
name: '/todos',
description: 'Show active TODO list (M3 wires TodoWrite tool).',
run() {
return ['No active todos — TodoWrite tool ships in M3.'];
description: 'Show active TODO list (TodoWrite tool — M3c-rest).',
async run(_args, ctx) {
try {
const { readTodos } = await import('@deepcode/core');
const path = await import('node:path');
const dir = path.join(ctx.sessions.root, ctx.sessionId);
const todos = await readTodos(dir);
if (todos.length === 0) return ['No active todos.'];
const lines = [`Todos (${todos.length}):`];
for (const t of todos) {
const marker =
t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '●' : '○';
const text = t.status === 'in_progress' ? t.activeForm : t.content;
lines.push(` ${marker} ${text}`);
}
return lines;
} catch (err) {
return [`(Error reading todos: ${(err as Error).message})`];
}
},
};

export const PluginsCommand: SlashCommand = {
name: '/plugins',
description: 'List wired plugins and what they contribute.',
run(_args, ctx) {
const plugins = ctx.wiredPlugins ?? [];
const warnings = ctx.pluginWarnings ?? [];
const lines: string[] = [];
if (plugins.length === 0 && warnings.length === 0) {
lines.push('No plugins installed.');
lines.push('');
lines.push('Install with: deepcode plugin install <path>');
lines.push('(M5 = manifest + hash pin; M5.1 = subprocess + RPC; M5.2 = live wire-up.)');
return lines;
}
if (plugins.length > 0) {
lines.push(`Active plugins (${plugins.length}):`);
for (const p of plugins) {
const events = p.contributedHookEvents.length
? ` hooks: ${p.contributedHookEvents.join(', ')}`
: '';
lines.push(` ● ${p.name}@${p.version}${events}`);
}
}
if (warnings.length > 0) {
if (lines.length > 0) lines.push('');
lines.push(`Warnings:`);
for (const w of warnings) lines.push(` ⚠ ${w}`);
}
return lines;
},
};

Expand All @@ -279,6 +335,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
InitCommand,
McpCommand,
TodosCommand,
PluginsCommand,
];

// ──────────────────────────────────────────────────────────────────────────
Expand Down
51 changes: 51 additions & 0 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@
// 5 aborted by signal (SIGINT / SIGTERM)

import {
BashTool,
CredentialsStore,
DeepSeekProvider,
EFFORT_PARAMS,
HookDispatcher,
ReadTool,
SessionManager,
ToolRegistry,
WebFetchTool,
WriteTool,
applyStyle,
buildSkillsDescriptionBlock,
closeAllMcpServers,
Expand All @@ -33,11 +37,13 @@ import {
makeSkillTool,
resolveCredentials,
runAgent,
wirePlugins,
type AgentEvent,
type DeepCodeSettings,
type Effort,
type McpClientHandle,
type Mode,
type WireResult,
} from '@deepcode/core';
import type { Writable } from 'node:stream';

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

// M5.2: wire installed plugins. We pipe their startup log to stderr to keep
// stdout reserved for the headless output payload.
let pluginsWire: WireResult | null = null;
try {
pluginsWire = await wirePlugins({
home: opts.home,
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilitiesHeadless(cwd),
log: (s) => errOutput.write(s + '\n'),
});
} catch (err) {
errOutput.write(`Plugin wire-up failed: ${(err as Error).message}\n`);
}

const sessions = new SessionManager();
const session = await sessions.create(cwd, { model });

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

return exitCode;
}

function buildPluginCapabilitiesHeadless(cwd: string) {
const ctx = { cwd };
return {
fs_read: async (path: string) => {
const r = await ReadTool.execute({ file_path: path }, ctx);
if (r.isError) throw new Error(r.content);
return r.content;
},
fs_write: async (path: string, content: string) => {
const r = await WriteTool.execute({ file_path: path, content }, ctx);
if (r.isError) throw new Error(r.content);
},
bash: async (cmd: string) => {
const r = await BashTool.execute({ command: cmd }, ctx);
const d = (r.data ?? {}) as { stderr?: string; exitCode?: number };
return {
stdout: r.content ?? '',
stderr: d.stderr ?? '',
exitCode: d.exitCode ?? (r.isError ? 1 : 0),
};
},
fetch: async (url: string) => {
const r = await WebFetchTool.execute({ url }, ctx);
if (r.isError) throw new Error(r.content);
return r.content;
},
};
}

function formatEventText(out: Writable, e: AgentEvent): void {
switch (e.type) {
case 'text_delta':
Expand Down
78 changes: 78 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
// Spec: docs/DEVELOPMENT_PLAN.md §5

import {
BashTool,
CredentialsStore,
DeepSeekProvider,
EFFORT_PARAMS,
HookDispatcher,
ReadTool,
SessionManager,
ToolRegistry,
WebFetchTool,
WriteTool,
applyStyle,
buildSkillsDescriptionBlock,
closeAllMcpServers,
Expand All @@ -20,12 +24,14 @@ import {
makeSkillTool,
resolveCredentials,
runAgent,
wirePlugins,
type DeepCodeSettings,
type Effort,
type McpClientHandle,
type Mode,
type AgentEvent,
type StoredMessage,
type WireResult,
} from '@deepcode/core';
import { createInterface } from 'node:readline/promises';
import type { Readable, Writable } from 'node:stream';
Expand Down Expand Up @@ -182,6 +188,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
allowedHttpHookUrls: settings.allowedHttpHookUrls,
});

// M5.2: wire installed plugins (discover + spawn + merge contributed hooks)
let pluginsWire: WireResult | null = null;
try {
pluginsWire = await wirePlugins({
home: opts.home,
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilities(cwd),
log: (s) => output.write(s + '\n'),
});
} catch (err) {
output.write(` ⊞ Plugins: wire-up failed — ${(err as Error).message}\n`);
}

let history: StoredMessage[] = [];
const ctx: SessionContext = {
cwd,
Expand All @@ -195,6 +215,15 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },
mcpServers,
mcpErrors,
wiredPlugins: pluginsWire?.plugins.map((p) => ({
name: p.plugin.manifest.name,
version: p.plugin.manifest.version,
contributedHookEvents: p.contributedHookEvents,
})),
pluginWarnings: [
...(pluginsWire?.hashMismatches ?? []),
...(pluginsWire?.spawnFailures.map((n) => `${n}: failed to start`) ?? []),
],
};

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

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

/**
* Build the capability bridge passed to plugin subprocesses (M5.2).
*
* Each capability invokes the host's existing tool implementation — which
* means plugin calls flow through the SAME read/write/exec gates as the
* agent. (mode + permissions + sandbox come from the ToolContext we pass in.)
*
* NOTE: this bridge does NOT carry mode/permissions yet — that's M5.2-ext.
* Today the plugin's `bash` calls are unsandboxed because we don't have a
* sandboxConfig in ctx here. Callers wanting hardening should set
* settings.sandbox.enabled and pass sandboxConfig in a later iteration.
*/
function buildPluginCapabilities(cwd: string): {
fs_read: (path: string) => Promise<string>;
fs_write: (path: string, content: string) => Promise<void>;
bash: (cmd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
fetch: (url: string, opts?: { method?: string; body?: string }) => Promise<string>;
} {
const ctx = { cwd };
return {
fs_read: async (path: string) => {
const r = await ReadTool.execute({ file_path: path }, ctx);
if (r.isError) throw new Error(r.content);
return r.content;
},
fs_write: async (path: string, content: string) => {
const r = await WriteTool.execute({ file_path: path, content }, ctx);
if (r.isError) throw new Error(r.content);
},
bash: async (cmd: string) => {
const r = await BashTool.execute({ command: cmd }, ctx);
const d = (r.data ?? {}) as { stderr?: string; exitCode?: number };
return {
stdout: r.content ?? '',
stderr: d.stderr ?? '',
exitCode: d.exitCode ?? (r.isError ? 1 : 0),
};
},
fetch: async (url: string, fopts?: { method?: string; body?: string }) => {
void fopts; // method/body deferred — WebFetch is GET-only
const r = await WebFetchTool.execute({ url }, ctx);
if (r.isError) throw new Error(r.content);
return r.content;
},
};
}

/**
* Find the bundled built-in skills directory.
* In dev: <repo>/packages/core/skills/.
Expand Down
3 changes: 2 additions & 1 deletion docs/BEHAVIOR_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
| `/init` | ✓ | ✓ (stub) | 🔄 — multi-phase interactive flow deferred to M3c-ext |
| `/mcp` | ✓ | ✓ | ✅ |
| `/add-dir` | ✓ | ✓ (records intent) | 🟡 — M3 will enforce |
| `/todos` | ✓ | ✓ (stub) | 🔄 — wired with TodoWrite tool (M3+) |
| `/todos` | ✓ | ✓ | ✅ — reads `<sessionDir>/todos.json` written by TodoWrite tool |
| `/plugins` | ✓ | ✓ | ✅ — lists wired plugins + contributed hook events + warnings (M5.2) |
| `/compact` | ✓ | ✓ auto-trigger | 🟡 — manual `/compact` slash command not exposed yet (auto works via agent loop) |
| `/btw` | ✓ | ✗ | 🔄 |
| `/recap` | ✓ | ✗ | 🔄 |
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
cwd: opts.cwd,
signal: opts.signal,
sandboxConfig: opts.sandboxConfig,
sessionDir: opts.session
? `${opts.session.manager.root}/${opts.session.id}`
: undefined,
};
const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 };
let turnsUsed = 0;
Expand Down
Loading
Loading