Skip to content

Commit 14d0f7f

Browse files
oratistclaude
authored
feat(cli): let a shell outlive the turn that opened it (#275)
The registry landed in #273 owned by a single runAgent call, so a shell opened in one turn was closed before the next began — a slower Bash with extra steps. The whole point of a shell that keeps its working directory is that the next message still has it. The REPL now owns one registry for the session and threads it through every turn, the same way it already owns the background-task manager. `cd`, `export`, and an activated virtualenv now survive from one message to the next. `/shells` lists what is open and where each started; `/shells close <id>` closes one and whatever is still running in it. That command is part of this change rather than a follow-up: making these processes outlive a turn is exactly what creates the need to see and stop them. Cleanup is a wrapper, not a habit. `startRepl` closes every shell in a finally, so it also runs when the session ends by throwing — these are real processes in their own process group and do not die with the CLI. Background tasks and sub-agents deliberately do not share the session's shells. Two agents interleaving commands in one shell would each be wrong about its state, so they get their own, closed when their run ends. A test pins that, including an assertion that the delegation actually happened — otherwise it would pass for the wrong reason if Task were ever missing from the registry. Co-authored-by: t <t@t> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f088686 commit 14d0f7f

6 files changed

Lines changed: 244 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
### ✨ Added
2424

25+
- **Persistent shells now last a whole CLI session, and `/shells` shows them.**
26+
The registry landed in #273 owned by a single `runAgent` call, which meant a
27+
shell opened in one turn was gone by the next — a slower `Bash` with extra
28+
steps. The REPL now owns one registry for the session and threads it through
29+
every turn, so `cd`, `export`, and an activated virtualenv survive from one
30+
message to the next.
31+
32+
`/shells` lists what is open and where each started; `/shells close <id>`
33+
closes one and whatever is still running in it. Worth having because the
34+
change is what makes these processes outlive a turn: without a view of them,
35+
the user has no way to see or stop something the agent left running.
36+
37+
Everything closes when the session ends, including when it ends by throwing —
38+
these are real processes in their own process group, so they do not die with
39+
the CLI. Background tasks and sub-agents deliberately do **not** share the
40+
session's shells: two agents interleaving commands in one shell would each be
41+
wrong about its state.
42+
2543
- **A shell that survives between tool calls** (#273) — `ShellOpen` / `ShellRun` /
2644
`ShellClose` / `ShellList`. Every `Bash` call is a fresh process, so `cd`,
2745
`export`, and `source venv/bin/activate` were forgotten the moment they

apps/cli/src/commands.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
SessionManager,
1111
SessionMeta,
1212
StoredMessage,
13+
ShellRegistry,
1314
TaskManager,
1415
VoiceStatus,
1516
} from '@deepcode/core';
@@ -183,6 +184,10 @@ export interface SessionContext {
183184
* /background. Same instance the agent loop uses, so tasks the agent starts
184185
* are visible here and vice-versa. */
185186
tasks?: TaskManager;
187+
/** Session-scoped persistent shells (REPL-injected) — backs /shells. Same
188+
* instance the agent loop uses, so a shell the agent opened is listed here
189+
* and closing one here really closes it. */
190+
shells?: ShellRegistry;
186191
}
187192

188193
export interface SlashCommand {
@@ -1220,6 +1225,40 @@ export const TasksCommand: SlashCommand = {
12201225
},
12211226
};
12221227

1228+
export const ShellsCommand: SlashCommand = {
1229+
name: '/shells',
1230+
description: 'List persistent shells this session, or `/shells close <id>` to close one.',
1231+
async run(args, ctx) {
1232+
if (!ctx.shells) return ['(Persistent shells are unavailable here.)'];
1233+
1234+
if (args[0] === 'close') {
1235+
const id = args[1]?.trim();
1236+
if (!id) return ['Usage: /shells close <id>'];
1237+
return [
1238+
(await ctx.shells.close(id))
1239+
? `Closed ${id} and anything still running in it.`
1240+
: `No open shell "${id}". Run /shells to list them.`,
1241+
];
1242+
}
1243+
if (args[0]) return [`Unknown argument "${args[0]}". Usage: /shells [close <id>]`];
1244+
1245+
const shells = ctx.shells.list();
1246+
if (shells.length === 0) {
1247+
return [
1248+
'No persistent shells open.',
1249+
'The agent opens one with its ShellOpen tool when commands need to build on each other.',
1250+
];
1251+
}
1252+
const lines = [`Persistent shells (${shells.length}):`];
1253+
for (const s of shells) {
1254+
lines.push(` ${s.id} ${s.cwd} last used ${s.lastUsedAt}${s.busy ? ' [running]' : ''}`);
1255+
}
1256+
lines.push('');
1257+
lines.push('Close one with `/shells close <id>`. All of them close when this session ends.');
1258+
return lines;
1259+
},
1260+
};
1261+
12231262
/** "Ready" status lines for /voice (non-interactive / headless fallback). */
12241263
export function voiceReadyLines(status: VoiceStatus): string[] {
12251264
return [
@@ -1433,6 +1472,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
14331472
BtwCommand,
14341473
TasksCommand,
14351474
BackgroundCommand,
1475+
ShellsCommand,
14361476
VoiceCommand,
14371477
];
14381478

apps/cli/src/repl.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ReadTool,
1212
RuntimeHost,
1313
SessionManager,
14+
ShellRegistry,
1415
TaskManager,
1516
ToolRegistry,
1617
WebFetchTool,
@@ -222,7 +223,29 @@ async function pickSessionId(
222223
return list[n - 1]!.id;
223224
}
224225

226+
/**
227+
* Run the interactive REPL.
228+
*
229+
* The persistent-shell registry is owned here, one per REPL session, so a shell
230+
* the agent opens in one turn is still there in the next — which is the whole
231+
* point of a shell that keeps its working directory. The wrapper closes every
232+
* one of them on the way out, including when the session throws: these are real
233+
* OS processes in their own process group, so they do not die with the CLI and
234+
* "the loop will remember" is not a guarantee.
235+
*
236+
* @param opts REPL configuration.
237+
* @returns Process exit code.
238+
*/
225239
export async function startRepl(opts: ReplOpts): Promise<number> {
240+
const shells = new ShellRegistry();
241+
try {
242+
return await runReplSession(opts, shells);
243+
} finally {
244+
await shells.closeAll();
245+
}
246+
}
247+
248+
async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise<number> {
226249
const { output, cwd } = opts;
227250

228251
// Load config + creds. Trust-gate first: in an untrusted directory, project
@@ -547,6 +570,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
547570
return { done, abort: () => ac.abort() };
548571
});
549572
ctx.tasks = tasks;
573+
ctx.shells = shells;
550574

551575
// Colour resolves once: a --no-color flag, NO_COLOR/FORCE_COLOR, or whether
552576
// stdout is actually a terminal. Piped output stays plain.
@@ -730,6 +754,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
730754
// Session-scoped manager: the agent's TaskCreate calls land here too, so
731755
// background tasks persist across turns and show up in /tasks.
732756
taskManager: tasks,
757+
// Session-scoped too, for the same reason: a shell opened this turn has to
758+
// still be there next turn or it is just a slower Bash. Deliberately NOT
759+
// given to the background-task runner below — two agents interleaving
760+
// commands in one shell would each be wrong about its state.
761+
shells,
733762
approval: async (toolName, input, verdict) => {
734763
output.write(
735764
`\n ${palette.yellow('⏸')} Approve ${palette.bold(toolName)}? ${palette.dim(verdict.reason)}\n`,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Tests for /shells, which drives the session-scoped ShellRegistry the REPL
2+
// owns (ctx.shells). Uses a real registry — a persistent shell is a real
3+
// process and the interesting assertions are about real ones being listed and
4+
// really closed.
5+
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
import { tmpdir } from 'node:os';
8+
import { SessionManager, ShellRegistry } from '@deepcode/core';
9+
import { CommandRegistry, type SessionContext } from './commands.js';
10+
11+
const reg = new CommandRegistry();
12+
const opened: ShellRegistry[] = [];
13+
14+
function registry(): ShellRegistry {
15+
const r = new ShellRegistry();
16+
opened.push(r);
17+
return r;
18+
}
19+
20+
afterEach(async () => {
21+
await Promise.all(opened.splice(0).map((r) => r.closeAll()));
22+
});
23+
24+
function ctx(overrides: Partial<SessionContext> = {}): SessionContext {
25+
return {
26+
cwd: tmpdir(),
27+
model: 'deepseek-chat',
28+
mode: 'default',
29+
effort: 'medium',
30+
settings: {},
31+
creds: { apiKey: 'sk-test' },
32+
sessionId: 's1',
33+
sessions: new SessionManager({ root: tmpdir() }),
34+
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },
35+
...overrides,
36+
};
37+
}
38+
39+
const run = async (args: string[], c: SessionContext): Promise<string> =>
40+
(await reg.match('/shells')!.cmd.run(args, c)).join('\n');
41+
42+
describe('/shells', () => {
43+
it('says what opens a shell when none are open', async () => {
44+
const out = await run([], ctx({ shells: registry() }));
45+
expect(out).toContain('No persistent shells open');
46+
expect(out).toContain('ShellOpen');
47+
});
48+
49+
it('lists an open shell with where it started', async () => {
50+
const shells = registry();
51+
const id = await shells.open({ cwd: tmpdir() });
52+
const out = await run([], ctx({ shells }));
53+
expect(out).toContain(id);
54+
expect(out).toContain(tmpdir());
55+
expect(out).toContain('close when this session ends');
56+
});
57+
58+
it('closes a shell for real, not just from the listing', async () => {
59+
const shells = registry();
60+
const id = await shells.open({ cwd: tmpdir() });
61+
expect(await run(['close', id], ctx({ shells }))).toContain(`Closed ${id}`);
62+
expect(shells.get(id)).toBeUndefined();
63+
expect(shells.list()).toEqual([]);
64+
});
65+
66+
it('reports an unknown id instead of claiming it closed something', async () => {
67+
const out = await run(['close', 'shell-999'], ctx({ shells: registry() }));
68+
expect(out).toContain('No open shell');
69+
});
70+
71+
it('asks for an id when close is given none', async () => {
72+
expect(await run(['close'], ctx({ shells: registry() }))).toContain('Usage:');
73+
});
74+
75+
it('rejects an unrecognised argument rather than silently listing', async () => {
76+
const out = await run(['kill', 'shell-1'], ctx({ shells: registry() }));
77+
expect(out).toContain('Unknown argument');
78+
});
79+
80+
it('says so when the host owns no registry', async () => {
81+
expect(await run([], ctx())).toContain('unavailable');
82+
});
83+
});

docs/BEHAVIOR_PARITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
5252
| `/background` ||| ✅ — runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too |
5353
| `/batch` ||| 🔄 — batch-of-prompts not yet wired (use `/background` per prompt) |
5454
| `/tasks` ||| ✅ — lists this session's background tasks; `/tasks <id>` shows one's status + output |
55+
| `/shells` ||| 🆕 DeepCode-only — lists this session's persistent shells; `/shells close <id>` closes one |
5556
| `/plan` ||| 🔄 — set via `/mode plan` in DeepCode |
5657
| `/login` / `/logout` ||| ✅ — /logout clears creds + exits; /login <key> stores a new key (next launch) |
5758
| `/export` ||| ✅ — writes the conversation to a markdown file |

packages/core/src/agent.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,26 @@ describe('runAgent', () => {
15011501
});
15021502

15031503
describe('persistent shells', () => {
1504+
/** The built-in registry plus one extra tool, so `Task` is still there. */
1505+
function withBuiltins(extra: ToolHandler): ToolRegistry {
1506+
const tools = new ToolRegistry();
1507+
tools.register(extra);
1508+
return tools;
1509+
}
1510+
1511+
/** Tool results from a run, keyed by the call id that produced them. */
1512+
function toolResults(history: StoredMessage[]): Map<string, string> {
1513+
const out = new Map<string, string>();
1514+
for (const msg of history) {
1515+
for (const block of msg.content) {
1516+
if (typeof block !== 'string' && block.type === 'tool_result') {
1517+
out.set(block.tool_use_id, block.content);
1518+
}
1519+
}
1520+
}
1521+
return out;
1522+
}
1523+
15041524
it('closes every shell it opened, even when the loop throws', async () => {
15051525
// A crashed run leaving live shell processes on the machine is the
15061526
// objection this capability has to answer, so the guarantee cannot rest
@@ -1555,6 +1575,59 @@ describe('runAgent', () => {
15551575
expect(seen?.list()).toEqual([]);
15561576
});
15571577

1578+
it("does not hand a sub-agent the parent session's shells", async () => {
1579+
// The REPL owns one registry for the whole session. A delegated agent
1580+
// sharing it could `cd` or close a shell the parent is mid-way through
1581+
// using, and each would then be wrong about its state. Sub-agents get
1582+
// their own, closed when their run ends.
1583+
const { ShellRegistry } = await import('./shell/registry.js');
1584+
const parentShells = new ShellRegistry();
1585+
let subShells: unknown = 'never ran';
1586+
1587+
const peek: ToolHandler = {
1588+
name: 'Peek',
1589+
definition: {
1590+
name: 'Peek',
1591+
description: 'reports the registry it was given',
1592+
inputSchema: { type: 'object', properties: {} },
1593+
},
1594+
execute: (_input, toolCtx) => {
1595+
subShells = toolCtx.shells;
1596+
return Promise.resolve({ content: 'peeked' });
1597+
},
1598+
};
1599+
1600+
const result = await runAgent({
1601+
provider: new MockProvider([
1602+
toolUse('delegating', {
1603+
type: 'tool_use',
1604+
id: 'task1',
1605+
name: 'Task',
1606+
input: { prompt: 'peek at the shells' },
1607+
}),
1608+
toolUse('peeking', { type: 'tool_use', id: 'p1', name: 'Peek', input: {} }),
1609+
endTurn('peeked'),
1610+
endTurn('done'),
1611+
]),
1612+
// Built-ins plus Peek: `new ToolRegistry([peek])` would replace them and
1613+
// there would be no Task tool to delegate through.
1614+
tools: withBuiltins(peek),
1615+
systemPrompt: '',
1616+
userMessage: 'go',
1617+
model: 'deepseek-chat',
1618+
cwd,
1619+
shells: parentShells,
1620+
});
1621+
1622+
// The delegation has to have actually happened, or `Peek` ran in the
1623+
// parent and the assertion below would pass for the wrong reason.
1624+
expect(toolResults(result.history).get('task1')).not.toMatch(/tool not found/);
1625+
1626+
expect(subShells).toBeDefined();
1627+
expect(subShells).not.toBe(parentShells);
1628+
await parentShells.closeAll();
1629+
});
1630+
15581631
it('leaves a host-owned registry alone', async () => {
15591632
// The host closes what the host owns; shells must survive between runs
15601633
// for a REPL session to be worth anything.

0 commit comments

Comments
 (0)