Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
608 changes: 608 additions & 0 deletions docs/design/web-shell-bounded-transcript-and-subagent-details.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ export interface ServeSessionAgentTaskStatus {
stats?: { totalTokens: number; toolUses: number; durationMs: number };
recentActivities?: Array<{ name: string; description: string; at: number }>;
prompt?: string;
/** Tool call in the parent session that launched this agent. */
toolUseId?: string;
/**
* `id` of the agent task that spawned this one; absent for agents
* launched by the top-level session. Mirrors `AgentTask.parentAgentId`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,9 @@ describe('buildSessionTasksStatus agent lineage', () => {
]);
expect(task.depth).toBe(0);
});

it('exposes the parent tool call that launched an agent', () => {
const [task] = serializedAgents([agentTask({ toolUseId: 'call-1' })]);
expect(task.toolUseId).toBe('call-1');
});
});
1 change: 1 addition & 0 deletions packages/cli/src/acp-integration/session/tasksSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function serializeAgentTask(
}
: {}),
...optionalField('prompt', entry.prompt),
...optionalField('toolUseId', entry.toolUseId),
};
}

Expand Down
213 changes: 213 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ import { createSessionOrganizationService } from '../session-organization-helper
import { replayTranscriptRecordPage } from '../../acp-integration/session/history-replay-page.js';
import { GENERATION_MAX_PROMPT_BYTES } from '../../acp-integration/generation.js';
import { requireSessionRuntime } from './session-runtime.js';
import {
parseVirtualSubagentSessionId,
type VirtualSubagentSessions,
} from '../virtual-subagent-sessions.js';
import {
resolveWorkspaceRuntimeFromParam,
sendUntrustedWorkspaceResponse,
Expand All @@ -105,6 +109,7 @@ interface RegisterSessionRoutesDeps {
promptDeadlineMs?: number;
sessionShellCommandEnabled: boolean;
languageCodes: string[];
virtualSubagentSessions?: VirtualSubagentSessions;
}

const WORKSPACE_TRANSCRIPT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
Expand Down Expand Up @@ -408,6 +413,7 @@ export function registerSessionRoutes(
daemonLog,
promptDeadlineMs,
sessionShellCommandEnabled,
virtualSubagentSessions,
} = deps;
const LANGUAGE_CODES = deps.languageCodes;
const transcriptCursorMasterKey = crypto.randomBytes(32);
Expand Down Expand Up @@ -1472,6 +1478,47 @@ export function registerSessionRoutes(
(action: 'load' | 'resume') => async (req: Request, res: Response) => {
const sessionId = requireSessionId(req, res);
if (!sessionId) return;
const virtualKey = parseVirtualSubagentSessionId(sessionId);
if (virtualKey) {
const route = `POST /session/:id/${action}`;
if (action !== 'load' || !virtualSubagentSessions) {
res.status(404).json({
error: `No session with id "${sessionId}"`,
Comment thread
ytahdn marked this conversation as resolved.
Resolved
code: 'session_not_found',
sessionId,
});
return;
}
const runtime = requireSessionRuntime({
sessionId: virtualKey.parentSessionId,
route,
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const session = await virtualSubagentSessions.load(
runtime,
sessionId,
clientId,
);
if (!session) {
res.status(404).json({
error: 'Subagent session not found',
code: 'session_not_found',
sessionId,
});
return;
}
res.status(200).json(session);
} catch (err) {
sendBridgeError(res, err, { route, sessionId });
}
return;
}
const body = safeBody(req);
const route = `POST /session/:id/${action}`;
let resolvedRuntime:
Expand Down Expand Up @@ -1674,6 +1721,100 @@ export function registerSessionRoutes(
app.post('/session/:id/load', mutate(), restoreSessionHandler('load'));
app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume'));

app.get('/session/:id/subagents/:toolCallId', async (req, res) => {
const route = 'GET /session/:id/subagents/:toolCallId';
const sessionId = requireSessionId(req, res);
if (!sessionId || !virtualSubagentSessions) return;
Comment thread
ytahdn marked this conversation as resolved.
Resolved
const toolCallId = req.params['toolCallId'];
if (!toolCallId || toolCallId.length > 500) {
res.status(400).json({
error: '`toolCallId` must be a non-empty tool call id',
code: 'invalid_tool_call_id',
});
return;
}
const runtime = requireSessionRuntime({
sessionId,
route,
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
try {
const resolved = await virtualSubagentSessions.resolve(
runtime,
sessionId,
toolCallId,
);
if (!resolved) {
res.status(404).json({
error: 'Subagent session not found',
code: 'session_not_found',
sessionId,
toolCallId,
});
return;
}
res.status(200).set('Cache-Control', 'no-store').json(resolved);
} catch (err) {
sendBridgeError(res, err, { route, sessionId });
}
});

app.post(
'/session/:id/subagents/:toolCallId/cancel',
Comment thread
ytahdn marked this conversation as resolved.
Resolved
mutate(),
async (req, res) => {
const route = 'POST /session/:id/subagents/:toolCallId/cancel';
const sessionId = requireSessionId(req, res);
if (!sessionId || !virtualSubagentSessions) return;
const toolCallId = req.params['toolCallId'];
if (!toolCallId || toolCallId.length > 500) {
res.status(400).json({
error: '`toolCallId` must be a non-empty tool call id',
code: 'invalid_tool_call_id',
});
return;
}
const runtime = requireSessionRuntime({
sessionId,
route,
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
try {
const resolved = await virtualSubagentSessions.resolve(
runtime,
sessionId,
toolCallId,
);
if (!resolved) {
res.status(404).json({
error: 'Subagent session not found',
code: 'session_not_found',
sessionId,
toolCallId,
});
return;
}
res
.status(200)
.json(
await runtime.bridge.cancelSessionTask(
sessionId,
resolved.taskId,
'agent',
),
);
} catch (err) {
sendBridgeError(res, err, { route, sessionId });
}
},
);

app.post(
'/session/:id/branch',
mutate(),
Expand Down Expand Up @@ -2007,6 +2148,30 @@ export function registerSessionRoutes(

app.get(
'/session/:id/context',
(req, res, next) => {
const sessionId = req.params['id'];
const key = sessionId
? parseVirtualSubagentSessionId(sessionId)
: undefined;
if (!sessionId || !key) {
next();
return;
}
const runtime = requireSessionRuntime({
sessionId: key.parentSessionId,
route: 'GET /session/:id/context',
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
res.status(200).json({
v: 1,
sessionId,
workspaceCwd: runtime.workspaceCwd,
state: {},
});
Comment thread
ytahdn marked this conversation as resolved.
Resolved
},
withOwnerReadSession(
'GET /session/:id/context',
async (_req, res, sessionId, runtime) => {
Expand Down Expand Up @@ -2045,6 +2210,30 @@ export function registerSessionRoutes(

app.get(
'/session/:id/supported-commands',
(req, res, next) => {
const sessionId = req.params['id'];
const key = sessionId
? parseVirtualSubagentSessionId(sessionId)
: undefined;
if (!sessionId || !key) {
next();
return;
}
const runtime = requireSessionRuntime({
sessionId: key.parentSessionId,
route: 'GET /session/:id/supported-commands',
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
res.status(200).json({
v: 1,
sessionId,
availableCommands: [],
availableSkills: [],
});
},
withOwnerReadSession(
'GET /session/:id/supported-commands',
async (_req, res, sessionId, runtime) => {
Expand Down Expand Up @@ -2515,6 +2704,20 @@ export function registerSessionRoutes(
app.post(
'/session/:id/heartbeat',
mutate(),
(req, res, next) => {
const sessionId = req.params['id'];
if (!sessionId || !parseVirtualSubagentSessionId(sessionId)) {
next();
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
res.status(200).json({
sessionId,
...(clientId ? { clientId } : {}),
lastSeenAt: Date.now(),
});
},
withOwnerMutableSession(
'POST /session/:id/heartbeat',
(req, res, sessionId, runtime) => {
Expand All @@ -2532,6 +2735,16 @@ export function registerSessionRoutes(
app.post(
'/session/:id/detach',
mutate(),
(req, res, next) => {
const sessionId = req.params['id'];
if (!sessionId || !parseVirtualSubagentSessionId(sessionId)) {
next();
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
res.status(204).end();
},
withOwnerMutableSession(
'POST /session/:id/detach',
async (req, res, sessionId, runtime) => {
Expand Down
Loading
Loading