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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
so anyone relying on the old behaviour must add rules — or start the server
with an explicit `--mode`. `--sandbox` also applies now; it did not before.

### ✨ Added

- **`thread/delete`** — the protocol could list, fork and archive threads but not
delete one, so the desktop deleted session files through Tauri instead. The
app-server is the single owner of thread storage; a client removing files
behind it can pull the ground out from under an open writer. Served under the
existing `threadManagement` capability, with the local writer kept as the
fallback for a sidecar too old to know the method.

### 🔒 Security

- **A sub-agent did not inherit the file contract.** The `Task` delegation
Expand All @@ -42,6 +51,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 🐛 Fixed

- **The desktop sidebar was a second reader of the session directory.** Archive
and delete went through Tauri while the protocol served the same threads, and
the list did too — `window.deepcode.sessions.list()` had preferred the
protocol since #231, but `Sidebar.tsx` bypassed the shim and called
`listSessions()` directly. All three now go through the owner, and deleting a
thread removes both its protocol snapshot and its canonical session
projection: `list` reads both, so removing one left the row reappearing on the
next refresh as an empty session that could not be opened.
- `deleteSession` refuses a session id that is not a single path segment, before
removing anything. It ends in a recursive delete of `<root>/<id>`, and `..` —
the id that resolves to the directory _above_ the sessions root — is spelled
entirely in characters an id may legitimately contain, so the character-class
check both it and the thread store relied on admitted it. Every in-tree caller
validates first; a delete this destructive should not depend on that.
- **`Grep` over a single file no longer prefixes every line with a colon.**
ripgrep omits the filename when the search path is one _file_ — there is
nothing to disambiguate — so its `--null` output carries no NUL, and rejoining
Expand Down
47 changes: 32 additions & 15 deletions apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@

import { useCallback, useEffect, useState, type JSX } from 'react';
import { projectName } from '../lib/project.js';
import {
listSessions,
sessionArchive,
sessionDelete,
sessionSetTitle,
type SessionMeta,
} from '../lib/tauri-api.js';
import { sessionSetTitle } from '../lib/tauri-api.js';
import { BrandMark } from './BrandMark.js';

interface SidebarProps {
Expand All @@ -28,6 +22,20 @@ interface SidebarProps {
onSessionRemoved?: (id: string) => void;
}

/**
* One row, as the app-server describes it.
*
* The sidebar used to read the session directory through Tauri while
* `window.deepcode.sessions.list()` read the same directory through the
* protocol — two readers of one directory, each with its own row shape and its
* own sort. This is the one the owner of that storage returns.
*/
interface SessionRow {
id: string;
title?: string;
updatedAtSecs: number;
}

type Bucket = 'Today' | 'Yesterday' | 'Earlier';

function bucketFor(updatedAtSecs: number, nowSecs: number): Bucket {
Expand All @@ -53,16 +61,25 @@ export function Sidebar({
onSwitchProject,
onSessionRemoved,
}: SidebarProps): JSX.Element {
const [sessions, setSessions] = useState<SessionMeta[]>([]);
const [sessions, setSessions] = useState<SessionRow[]>([]);
const [now, setNow] = useState<number>(Math.floor(Date.now() / 1000));
// Inline rename: which session is being edited + its draft title.
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [query, setQuery] = useState('');

const reload = useCallback(() => {
void listSessions()
.then(setSessions)
void window.deepcode.sessions
.list()
.then((rows) =>
setSessions(
rows.map((row) => ({
id: row.id,
title: row.title,
updatedAtSecs: Math.floor(new Date(row.updatedAt).getTime() / 1000),
})),
),
)
.catch(() => setSessions([]));
}, []);

Expand Down Expand Up @@ -92,7 +109,7 @@ export function Sidebar({

async function handleArchive(id: string): Promise<void> {
try {
await sessionArchive(id);
await window.deepcode.sessions.archive({ id });
if (id === activeSessionId) onSessionRemoved?.(id);
reload();
} catch {
Expand All @@ -105,7 +122,7 @@ export function Sidebar({
return;
}
try {
await sessionDelete(id);
await window.deepcode.sessions.delete({ id });
if (id === activeSessionId) onSessionRemoved?.(id);
reload();
} catch {
Expand All @@ -119,13 +136,13 @@ export function Sidebar({
(s) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q),
)
: sessions;
const grouped: Record<Bucket, SessionMeta[]> = {
const grouped: Record<Bucket, SessionRow[]> = {
Today: [],
Yesterday: [],
Earlier: [],
};
for (const s of visible) {
grouped[bucketFor(s.updated_at_secs, now)].push(s);
grouped[bucketFor(s.updatedAtSecs, now)].push(s);
}

return (
Expand Down Expand Up @@ -227,7 +244,7 @@ export function Sidebar({
) : (
<span className="label">{s.title?.trim() ? s.title : shortTitle(s.id)}</span>
)}
<span className="meta">{relTime(s.updated_at_secs, now)}</span>
<span className="meta">{relTime(s.updatedAtSecs, now)}</span>
{editingId !== s.id && (
<span className="row-actions">
<button
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/lib/protocol-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,41 @@ describe('DesktopProtocolAgent', () => {
});
});
});

// The sidebar removed threads through Tauri while the protocol served
// `thread/archive` — a second writer against storage the app-server owns.
describe('thread removal', () => {
it('archives through the protocol', async () => {
const transport = new FakeTransport();
const agent = new DesktopProtocolAgent(transport, () => undefined);
expect(await agent.archiveThread('thread-1')).toBe(true);
expect(transport.requests).toContainEqual({
method: 'thread/archive',
params: { threadId: 'thread-1' },
});
});

it('deletes through the protocol', async () => {
const transport = new FakeTransport();
const agent = new DesktopProtocolAgent(transport, () => undefined);
expect(await agent.deleteThread('thread-1')).toBe(true);
expect(transport.requests).toContainEqual({
method: 'thread/delete',
params: { threadId: 'thread-1' },
});
});

it('reports false — not an exception — when the server cannot manage threads', async () => {
// The caller falls back to the local writer on false. Throwing would make a
// sidecar that predates the method look like a delete that failed.
const transport = new FakeTransport();
const original = transport.connect.bind(transport);
transport.connect = async () => {
const init = await original();
return { ...init, capabilities: { ...init.capabilities, threadManagement: false } };
};
const agent = new DesktopProtocolAgent(transport, () => undefined);
expect(await agent.deleteThread('thread-1')).toBe(false);
expect(transport.requests).toHaveLength(0);
});
});
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/protocol-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export class DesktopProtocolAgent {
return true;
}

async deleteThread(threadId: string): Promise<boolean> {
const initialized = await this.transport.connect();
if (!initialized.capabilities.threadManagement) return false;
await this.transport.request('thread/delete', { threadId });
return true;
}

async resume(threadId: string): Promise<ThreadSnapshot> {
await this.transport.connect();
if (this.threadId && this.threadId !== threadId) {
Expand Down Expand Up @@ -387,6 +394,10 @@ export function archiveProtocolThread(threadId: string) {
return defaultAgent.archiveThread(threadId);
}

export function deleteProtocolThread(threadId: string) {
return defaultAgent.deleteThread(threadId);
}

export function resumeProtocolThread(threadId: string) {
return defaultAgent.resume(threadId);
}
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/lib/window-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
abortProtocolTurn,
answerProtocolRequest,
approveProtocolRequest,
archiveProtocolThread,
deleteProtocolThread,
getConfigDiagnostics,
installProtocolAgentEmitter,
listProtocolThreads,
Expand All @@ -23,6 +25,8 @@ import {
loadSettingsFile,
openUrl,
saveCredentials,
sessionArchive,
sessionDelete,
sessionRead,
} from './tauri-api.js';

Expand Down Expand Up @@ -91,6 +95,27 @@ export function installTauriShim(): void {
updatedAt: new Date(r.updated_at_secs * 1000).toISOString(),
}));
},
// Archive and delete follow list: the app-server owns thread storage and
// is its single writer, so a renderer removing files through Tauri is
// reaching around the owner — and for delete that can pull the ground out
// from under an open writer. The Tauri command stays as the fallback for a
// sidecar too old to serve the method, same as list.
async archive({ id }) {
try {
if (await archiveProtocolThread(id)) return;
} catch {
/* fall through to the local writer */
}
await sessionArchive(id);
},
async delete({ id }) {
try {
if (await deleteProtocolThread(id)) return;
} catch {
/* fall through to the local writer */
}
await sessionDelete(id);
},
async resume({ id }) {
// The snapshot carries every completed item — approvals, ask-user
// exchanges, errors, review findings. The session projection keeps only
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preview-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise<void> {
archivedThreads.add(String(request.params.threadId));
await respond({ archived: true });
break;
case 'thread/delete':
// The fixture cannot distinguish the two on its own storage, but the
// journey only needs the row to leave the list — what it is asserting is
// that the button reached the protocol at all.
archivedThreads.add(String(request.params.threadId));
await respond({ deleted: true });
break;
case 'workspace/diff':
await respond({
repository: true,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export interface DeepCodeAPI {
resume: (args: {
id: string;
}) => Promise<{ history: unknown[]; sessionId: string; thread?: unknown }>;
/** Move a thread out of the listing. Reversible on disk. */
archive: (args: { id: string }) => Promise<void>;
/** Irreversibly drop a thread and everything it owns. */
delete: (args: { id: string }) => Promise<void>;
};
plugins: {
list: () => Promise<PluginRow[]>;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ export class AppServer {
return this.lifecycle.forkThread(requiredId(request.params, 'threadId'), traceId);
case 'thread/archive':
return this.lifecycle.archiveThread(requiredId(request.params, 'threadId'));
case 'thread/delete':
return this.lifecycle.deleteThread(requiredId(request.params, 'threadId'));
case 'turn/start':
return this.startTurn(request.params, traceId);
case 'turn/interrupt':
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,57 @@ describe('CanonicalThreadStore', () => {
await expect(store.load(meta.id)).resolves.toEqual(imported);
});
});

describe('CanonicalThreadStore.delete', () => {
const thread = (id: string): ThreadSnapshot => ({
id,
cwd: '/workspace',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:02.000Z',
turns: [
{
id: `${id}-turn-1`,
threadId: id,
status: 'completed',
startedAt: '2026-08-01T00:00:01.000Z',
completedAt: '2026-08-01T00:00:02.000Z',
items: [
{
id: `${id}-item-1`,
type: 'user_message',
payload: { text: 'hello', model: 'deepseek-chat' },
completedAt: '2026-08-01T00:00:01.000Z',
},
],
},
],
});

it('removes both representations, so the thread cannot come back', async () => {
// The snapshot and the canonical session projection share an id and are two
// views of one thing. `list` reads both, so removing one and leaving the
// other means the row reappears on the next refresh — as an empty session
// that cannot be opened.
const { store, sessions } = await fixture();
await store.save(thread('thread-del'));

expect(await store.load('thread-del')).not.toBeNull();
expect(await sessions.list()).toHaveLength(1);

await store.delete('thread-del');

expect(await store.load('thread-del')).toBeNull();
expect(await sessions.list()).toHaveLength(0);
expect(await store.list()).toHaveLength(0);
});

it('leaves other threads alone', async () => {
const { store } = await fixture();
await store.save(thread('thread-keep'));
await store.save(thread('thread-drop'));

await store.delete('thread-drop');

expect((await store.list()).map((t) => t.id)).toEqual(['thread-keep']);
});
});
Loading
Loading