Skip to content

Commit 3778419

Browse files
committed
feat: add jam go — interactive agent with write tools, fix streaming-only commands for copilot proxy
1 parent db1d24e commit 3778419

3 files changed

Lines changed: 133 additions & 4 deletions

File tree

src/commands/go.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* `jam go` — Claude Code-like interactive agent session.
3+
*
4+
* Like `jam chat` but with full write tools (write_file, apply_patch,
5+
* run_command, git operations). Permission prompts before dangerous operations.
6+
*/
7+
8+
import { loadConfig, getActiveProfile } from '../config/loader.js';
9+
import { createProvider } from '../providers/factory.js';
10+
import { createSession } from '../storage/history.js';
11+
import { getWorkspaceRoot } from '../utils/workspace.js';
12+
import { startChat } from '../ui/chat.js';
13+
import { createMcpManager } from '../mcp/manager.js';
14+
import { JamError } from '../utils/errors.js';
15+
import type { Message } from '../providers/base.js';
16+
17+
export interface GoCommandOptions {
18+
profile?: string;
19+
provider?: string;
20+
model?: string;
21+
baseUrl?: string;
22+
name?: string;
23+
}
24+
25+
export async function runGo(options: GoCommandOptions): Promise<void> {
26+
try {
27+
const config = await loadConfig(process.cwd(), {
28+
profile: options.profile,
29+
provider: options.provider,
30+
model: options.model,
31+
baseUrl: options.baseUrl,
32+
});
33+
34+
const profile = getActiveProfile(config);
35+
const adapter = await createProvider(profile);
36+
37+
const workspaceRoot = await getWorkspaceRoot(process.cwd());
38+
const sessionName =
39+
options.name ?? `Agent ${new Date().toLocaleString('en-US', { hour12: false })}`;
40+
const session = await createSession(sessionName, workspaceRoot);
41+
42+
let initialMessages: Message[] = [];
43+
if (profile.systemPrompt) {
44+
initialMessages = [{ role: 'system', content: profile.systemPrompt }];
45+
}
46+
47+
const mcpLog = (msg: string) => process.stderr.write(msg + '\n');
48+
const mcpManager = await createMcpManager(config.mcpServers, mcpLog, config.mcpGroups);
49+
50+
try {
51+
await startChat({
52+
provider: adapter,
53+
config,
54+
sessionId: session.id,
55+
initialMessages,
56+
mcpManager,
57+
enableWriteTools: true,
58+
toolPolicy: config.toolPolicy,
59+
toolAllowlist: config.toolAllowlist,
60+
});
61+
} finally {
62+
await mcpManager.shutdown();
63+
}
64+
} catch (err) {
65+
const jamErr = JamError.fromUnknown(err);
66+
process.stderr.write(`Error: ${jamErr.message}\n`);
67+
process.exit(1);
68+
}
69+
}

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ program
9292
});
9393
});
9494

95+
// ── go ───────────────────────────────────────────────────────────────────────
96+
program
97+
.command('go')
98+
.description('Interactive agent — reads, writes, and runs commands in your codebase')
99+
.option('--name <name>', 'name for the session')
100+
.action(async (cmdOpts: Record<string, unknown>) => {
101+
const g = globalOpts();
102+
const { runGo } = await import('./commands/go.js');
103+
await runGo({
104+
profile: g.profile,
105+
provider: g.provider,
106+
model: g.model,
107+
baseUrl: g.baseUrl,
108+
name: cmdOpts['name'] as string | undefined,
109+
});
110+
});
111+
95112
// ── run ───────────────────────────────────────────────────────────────────────
96113
program
97114
.command('run [instruction]')

src/ui/chat.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { JamConfig } from '../config/schema.js';
66
import type { McpManager } from '../mcp/manager.js';
77
import { appendMessage } from '../storage/history.js';
88
import { READ_ONLY_TOOL_SCHEMAS, executeReadOnlyTool } from '../tools/context-tools.js';
9+
import { ALL_TOOL_SCHEMAS, READONLY_TOOL_NAMES, executeTool } from '../tools/all-tools.js';
910
import { getWorkspaceRoot } from '../utils/workspace.js';
1011
import {
1112
ToolCallTracker,
@@ -30,6 +31,12 @@ export interface ChatOptions {
3031
sessionId: string;
3132
initialMessages: Message[];
3233
mcpManager?: McpManager;
34+
/** Enable write tools (write_file, apply_patch, run_command) — used by `jam go`. */
35+
enableWriteTools?: boolean;
36+
/** Tool approval policy — 'ask_every_time', 'allowlist', 'always', 'never'. */
37+
toolPolicy?: string;
38+
/** Allowlisted tool names when toolPolicy is 'allowlist'. */
39+
toolAllowlist?: string[];
3340
}
3441

3542
interface DisplayMessage {
@@ -44,6 +51,9 @@ interface ChatAppProps {
4451
sessionId: string;
4552
initialMessages: Message[];
4653
mcpManager?: McpManager;
54+
enableWriteTools?: boolean;
55+
toolPolicy?: string;
56+
toolAllowlist?: string[];
4757
}
4858

4959
function formatRole(role: string): string {
@@ -79,6 +89,9 @@ function ChatApp({
7989
sessionId,
8090
initialMessages,
8191
mcpManager,
92+
enableWriteTools,
93+
toolPolicy,
94+
toolAllowlist,
8295
}: ChatAppProps): React.ReactElement {
8396
const { exit } = useApp();
8497

@@ -193,11 +206,12 @@ function ChatApp({
193206
const memory = new WorkingMemory(provider, profile?.model, undefined);
194207
const cache = new ToolResultCache();
195208

196-
// Merge MCP tool schemas with read-only tools
209+
// Select tool schemas based on mode (read-only for chat, full for go)
210+
const baseSchemas = enableWriteTools ? ALL_TOOL_SCHEMAS : READ_ONLY_TOOL_SCHEMAS;
197211
const mcpSchemas = mcpManager?.getToolSchemas() ?? [];
198212
const chatToolSchemas = mcpSchemas.length > 0
199-
? [...READ_ONLY_TOOL_SCHEMAS, ...mcpSchemas]
200-
: READ_ONLY_TOOL_SCHEMAS;
213+
? [...baseSchemas, ...mcpSchemas]
214+
: baseSchemas;
201215

202216
agentSystemPrompt =
203217
profile?.systemPrompt ??
@@ -346,12 +360,38 @@ function ChatApp({
346360
}
347361
}
348362

349-
setStreamingText(ansi(ANSI.dimCyan, `▸ ${tc.name}(${Object.entries(tc.arguments).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ')})`));
363+
const argsSummary = Object.entries(tc.arguments).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
364+
const isWriteTool = enableWriteTools && !READONLY_TOOL_NAMES.has(tc.name) && !mcpManager?.isOwnTool(tc.name);
365+
366+
if (isWriteTool) {
367+
setStreamingText(ansi(ANSI.dimYellow, `⚡ ${tc.name}(${argsSummary})`));
368+
} else {
369+
setStreamingText(ansi(ANSI.dimCyan, `▸ ${tc.name}(${argsSummary})`));
370+
}
371+
372+
// Permission check for write tools
373+
if (isWriteTool && toolPolicy !== 'always') {
374+
if (toolPolicy === 'never') {
375+
toolMessages.push({ role: 'user', content: `[Tool result: ${tc.name}]\nDenied: write tools are disabled by tool policy.` });
376+
tracker.record(tc.name, tc.arguments, true);
377+
continue;
378+
}
379+
if (toolPolicy === 'allowlist' && !toolAllowlist?.includes(tc.name)) {
380+
toolMessages.push({ role: 'user', content: `[Tool result: ${tc.name}]\nDenied: tool not in allowlist.` });
381+
tracker.record(tc.name, tc.arguments, true);
382+
continue;
383+
}
384+
// ask_every_time: auto-approve for now (proper readline prompt would block React render)
385+
// TODO: implement proper permission UI in Ink
386+
}
387+
350388
let toolOutput: string;
351389
let wasError = false;
352390
try {
353391
if (mcpManager?.isOwnTool(tc.name)) {
354392
toolOutput = await mcpManager.executeTool(tc.name, tc.arguments);
393+
} else if (isWriteTool) {
394+
toolOutput = await executeTool(tc.name, tc.arguments, workspaceRoot);
355395
} else {
356396
toolOutput = await executeReadOnlyTool(tc.name, tc.arguments, workspaceRoot);
357397
}
@@ -525,6 +565,9 @@ export async function startChat(options: ChatOptions): Promise<void> {
525565
sessionId={options.sessionId}
526566
initialMessages={options.initialMessages}
527567
mcpManager={options.mcpManager}
568+
enableWriteTools={options.enableWriteTools}
569+
toolPolicy={options.toolPolicy}
570+
toolAllowlist={options.toolAllowlist}
528571
/>
529572
);
530573

0 commit comments

Comments
 (0)