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
106 changes: 99 additions & 7 deletions .specs/cloud-agent-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ which implementation they are on.

## Status

Draft -- created 2026-08-24, revised 2026-08-24 after an audit against the
implementation.
Draft -- created 2026-08-24, revised 2026-08-26 for worktree navigation, grouped
browser-chat tabs, and their filesystem durability boundary.

## Conventions

Expand All @@ -26,6 +26,8 @@ they appear in all capitals, as shown here.

- **Session**: one chat bound to one repository and one environment.
- **Environment**: the isolated workspace where the agent runs.
- **Worktree**: a group of independent browser chats that share the same
checked-out repository files while their environment exists.
- **Preparation**: visible setup of that environment -- clone, branch, setup
commands, restore.
- **Turn**: one user message and the agent work it triggers.
Expand Down Expand Up @@ -108,13 +110,88 @@ repository.
the chat, and close it. A read-only session MUST NOT offer terminals.
2. A pull request the session opened MUST be visible with its current state.

### Shared Worktrees

1. Eligible browser chats for the same worktree MUST appear as exactly one
selectable row in the sidebar, without nested chat rows. Its label MUST use
the user-set worktree name, otherwise the first surviving chat's non-default
title, otherwise the repository and branch. This fallback MUST NOT depend on
which chats are visible in the current search or recent-session page.
Selecting the worktree MUST open its latest chat. The row MUST remain visibly
selected while any sibling is active and MUST reflect busy, question, or
permission activity across all its chats. Its timestamp and overflow menu
MUST share the session-row action slot. Associated pull-request information
MUST use the existing session indicator and refresh behavior.
2. Each open chat in the selected worktree MUST appear as a distinct tab in the
main chat header. Each tab MUST reflect its chat's live title, status, and
progress, with only an **X** close action and no overflow menu. Double-clicking
or double-tapping a tab MUST rename it inline; keyboard rename MUST remain
available. Selecting a tab MUST open that chat's own address and transcript.
The header MUST offer a split **+** / downward-chevron control: **+** opens a
new chat, and the chevron offers **New chat** and eligible **New terminal**
actions. Creation controls and the session list MUST remain accessible when
tabs overflow, without a vertical scrollbar in the tab strip.
3. The worktree-row action **New chat**, the header action **New chat**, and an
eligible chat's exact trimmed `/new` command MUST immediately open a new
empty chat in the same worktree without stopping, waiting for, or adding a
message to an already-running chat. The composer MUST suggest `/new` while
typing. Header and worktree-row actions MUST add a tab; `/new` MUST replace
the current tab in the same position only after creation succeeds, preserving
the previous chat.
4. A failed new-chat action MUST keep the current chat and tab open, restore its
composer, and show a retryable error. Attachments MUST NOT be discarded.
5. Grouped chats MUST see the same uncommitted files and Git changes while their
shared environment exists. They MAY run simultaneously; concurrent file or
Git changes are visible shared-state races.
6. Each chat MUST keep its own streamed output, transcript, questions,
permissions, stop control, and recovery. Stopping or answering one chat MUST
NOT affect another chat in the worktree.
7. Grouped chats MUST support platform-managed auto-commit and push after
successful turns, enabled by default for new worktrees and honoring explicit
opt-out. New sibling chats MUST inherit the source chat's auto-commit setting.
Platform-managed commit and push operations MUST be serialized per worktree.
Each commit captures the shared checkout and MAY include sibling-chat edits.
Rebuilt worktrees MUST restore their pushed working branch rather than restart
from the repository's default branch. An agent MAY still perform explicit Git
operations during its chat.
8. Deleting one chat MUST leave its siblings usable. Deleting the final chat
MUST remove the worktree group from the session list.
9. Existing ungrouped and legacy chats MUST remain individual sidebar rows and
retain their current behavior. Worktree grouping MUST NOT change
native-mobile chat behavior.
10. Closing a chat tab MUST NOT delete its saved session, discard its transcript,
or stop its running work. Closed-tab preferences and tab order MUST survive
refresh and remain scoped to the current user and personal or organization
context.
11. The selected worktree MUST offer a **Sessions** list inside the split control's
chevron menu, containing only closed chats and excluding already-open tabs.
Most recently closed chats MUST appear first, including after refresh;
session creation or activity timestamps MUST NOT determine this order.
Selecting a closed chat MUST reopen it and remove it from that list. Closing
the last chat MUST retain the selected worktree and access to its saved
sessions and new-chat action; existing terminal tabs MUST remain usable.
12. The user MUST be able to rename a worktree independently of its chats.
A custom name MUST survive refresh and new sibling creation without changing
chat titles or activity timestamps. Later chat-title changes MUST NOT
override a custom worktree name.
13. Deleting a worktree MUST require confirmation and remove all its chats,
descendants, saved content, terminals, checkout files, and associated runtime
state. It MUST preserve other worktrees and destroy a physical sandbox only
when that sandbox is exclusively owned by the deleted worktree. Ownership
and current personal or organization access MUST be checked server-side.
Incomplete cleanup MUST remain recoverable, MUST NOT report success, and
MUST NOT permit late creation or ingestion to resurrect the worktree.

### Persistence

1. Refresh MUST restore the transcript, the preparation history that was shown,
delivery failures, and any still-open question or permission.
2. Refresh MUST NOT start a new turn.
3. If the environment dies, the next prompt MUST recover in the same chat. The
user MUST NOT be forced to start a new session.
4. Recovery MUST preserve each worktree chat, its transcript, and its grouping.
Uncommitted files are not guaranteed to survive replacement of the shared
physical environment.

### Errors

Expand All @@ -136,11 +213,11 @@ repository.

## Out of Scope

The Cloud pages around the chat (sessions list, MCP Gateway, triggers,
webhooks), profile and organization administration, the mobile app, and the CLI.
Also out of scope, and deliberately so: which sandbox provider runs the
environment, the shape of session ids, and the streaming wire protocol. A tester
MUST NOT be able to tell those apart from the chat.
Cloud administration surfaces beyond the existing chat sidebar (MCP Gateway,
triggers, webhooks), profile and organization administration, the mobile app,
and the CLI. Also out of scope, and deliberately so: which sandbox provider
runs the environment, the shape of session ids, and the streaming wire protocol.
A tester MUST NOT be able to tell those apart from the chat.

## Not Yet Implemented

Expand All @@ -157,6 +234,21 @@ The following use SHOULD and are not enforced today:

## Changelog

### 2026-09-01 -- Shared worktree auto-commit

- Restored automatic commit and push for shared worktrees, enabled by default,
with explicit opt-out, inherited sibling settings, and serialized per-worktree
Git finalization. Commits are checkpoints of the shared checkout, not isolated
changes belonging to one chat.

### 2026-08-26 -- Shared worktree pilot

- Defined selectable worktree sidebar rows, live per-chat header tabs,
concurrent sibling creation, shared files, independent chat controls,
disabled auto-commit, and group deletion.
- Defined transcript and group recovery separately from physical filesystem
durability without changing terminal or permission requirements.

### 2026-08-24 -- Audit revision

- Corrected: a failing setup command fails the turn on a rebuild too, not only
Expand Down
3 changes: 2 additions & 1 deletion ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra
- `KILO_BIN_PATH` - Path or name of the `kilo` CLI binary; used by `services/cloud-agent-next/scripts/update-default-slash-commands.mjs`. [SERVER]
- `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER]
- `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER]
- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. [SERVER]
- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER]
- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Defaults to empty/off and also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER]
- `VERCEL_SANDBOX_ORG_IDS` - Comma-separated org IDs routed to Vercel sandboxes. Empty is off. `*` includes personal accounts. [SERVER]
- `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function session(id: string): StoredSession {
title: `${id} title`,
organization_id: 'org-1',
cloud_agent_session_id: null,
cloud_agent_worktree_id: null,
parent_session_id: null,
created_on_platform: 'cli',
git_url: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const session: StoredSession = {
title: 'Fix login bug',
organization_id: 'org-1',
cloud_agent_session_id: null,
cloud_agent_worktree_id: null,
parent_session_id: null,
created_on_platform: 'cli',
git_url: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function session(id: string, over: Partial<StoredSession> = {}): StoredSession {
session_id: id,
title: id,
cloud_agent_session_id: null,
cloud_agent_worktree_id: null,
parent_session_id: null,
organization_id: null,
created_on_platform: 'cloud-agent',
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/session-list-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function makeSession(
session_id: 's1',
title: 'Untitled',
cloud_agent_session_id: null,
cloud_agent_worktree_id: null,
parent_session_id: null,
organization_id: null,
created_on_platform: 'cli',
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/(app)/cloud/(agent)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Suspense } from 'react';
import { CloudAgentProvider } from '@/components/cloud-agent-next/CloudAgentProvider';
import { CloudSidebarLayout } from '@/components/cloud-agent-next/CloudSidebarLayout';
import { getUserFromAuthOrRedirect } from '@/lib/user/server';

export default async function CloudAgentLayout({ children }: { children: React.ReactNode }) {
const user = await getUserFromAuthOrRedirect();

export default function CloudAgentLayout({ children }: { children: React.ReactNode }) {
return (
<CloudAgentProvider>
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
<CloudSidebarLayout>{children}</CloudSidebarLayout>
<CloudSidebarLayout currentUserId={user.id}>{children}</CloudSidebarLayout>
</Suspense>
</CloudAgentProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { CloudAgentProvider } from '@/components/cloud-agent-next/CloudAgentProvider';
import { CloudSidebarLayout } from '@/components/cloud-agent-next/CloudSidebarLayout';
import { getUserFromAuthOrRedirect } from '@/lib/user/server';

export default async function OrgCloudAgentLayout({
children,
Expand All @@ -9,13 +10,15 @@ export default async function OrgCloudAgentLayout({
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [{ id }, user] = await Promise.all([params, getUserFromAuthOrRedirect()]);
const organizationId = decodeURIComponent(id);

return (
<CloudAgentProvider organizationId={organizationId}>
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
<CloudSidebarLayout organizationId={organizationId}>{children}</CloudSidebarLayout>
<CloudSidebarLayout currentUserId={user.id} organizationId={organizationId}>
{children}
</CloudSidebarLayout>
</Suspense>
</CloudAgentProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,66 @@ describe('ChatInput finalize failure', () => {
}
});

it.each([true, false])(
'routes /new locally and rejects files before finalization with attachments=%s',
async hasAttachments => {
const upload = buildMockUpload({
attachments: hasAttachments
? [
{
id: 'attachment',
file: new File(['notes'], 'notes.txt', { type: 'text/plain' }),
contentType: 'text/plain',
kind: 'document',
status: 'complete',
progress: 100,
r2Key: 'owner/cloud-agent/message/notes.txt',
},
]
: [],
});
mockedUseCloudAgentAttachmentUpload.mockReturnValue(upload);
const onSend = jest.fn(async () => true);
const onNewChat = jest.fn(async () => true);
const dom = installLinkedomDom();
let root: Root | undefined;
try {
act(() => {
root = createRoot(dom.container);
root.render(
createElement(ChatInput, {
onSend,
onNewChat,
initialValue: '/new ',
attachmentUploadOptions: { messageUuid: 'test-message-uuid' },
})
);
});
await act(async () => {
pressEnter(dom.container);
});

expect(onSend).not.toHaveBeenCalled();
if (hasAttachments) {
expect(upload.finalizeAttachments).not.toHaveBeenCalled();
expect(onNewChat).not.toHaveBeenCalled();
expect(upload.removeAttachment).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('Files cannot be attached to slash commands', {
description: 'Remove the files or type a plain prompt instead.',
});
expect(dom.container.querySelector('textarea')?.value).toBe('/new ');
} else {
expect(upload.finalizeAttachments).toHaveBeenCalledTimes(1);
expect(onNewChat).toHaveBeenCalledTimes(1);
expect(dom.container.querySelector('textarea')?.value).toBe('');
}
} finally {
act(() => root?.unmount());
dom.cleanup();
}
}
);

it('ignores a second Enter while the link RPC is still in flight', async () => {
let resolveFinalize!: (value: CloudAgentAttachments | undefined) => void;
const finalizeAttachments = jest.fn(
Expand Down
Loading
Loading