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
338 changes: 257 additions & 81 deletions apps/server/src/agents/manager.ts

Large diffs are not rendered by default.

83 changes: 76 additions & 7 deletions apps/server/src/agents/tmux/command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createJobMcpToken,
createReleaseUpdateToken,
} from "../../auth.js";
import { buildChatEnvelope } from "../../chat/envelope.js";
import type { AppConfig } from "../../config.js";
import { PLUGIN_AGENT_TYPES } from "../../shared/agent-types.js";
import { buildCursorDispatchToolGuidance } from "../../shared/mcp/cursor-dispatch-guidance.js";
Expand Down Expand Up @@ -87,6 +88,60 @@ function stripModelArgs(args: string[]): string[] {
return filtered;
}

/** A startup file as `seedInitialMedia` reports it, for the first turn. */
export type StartupMedia = {
fileName: string;
displayName: string;
source: string;
description: string | null;
};

/**
* The Chat feed's launch post, fixed before the CLI command is built so the
* first turn can carry its id. `attachmentLines` are the recorder's own
* envelope lines for the startup files, links and pins — one source, so the
* pane and the post agree.
*/
export type ChatLaunchPost = {
messageId: string;
attachmentLines: string[];
};

export type StartupTurnInput = {
initialPrompt?: string;
initialPins?: AgentPin[];
initialMedia?: StartupMedia[];
chatLaunchPost?: ChatLaunchPost | null;
};

/**
* The agent's first user turn. With the chat surface on and a launch post
* recorded, the prompt is wrapped in the same `--- DISPATCH CHAT ---`
* envelope a Chat message is injected with (id = the launch post, the
* attachments listed the same way, the trailer pointing the agent at
* dispatch_chat_post), so an agent started from the Chat tab knows to answer
* there. Job runs never wrap (their prompt is a system-prompt append), and
* with the flag off — or nothing recorded — the plain startup prompt is used.
*/
export function buildStartupTurn(
startup: StartupTurnInput,
opts: { chatSurface?: boolean; jobRunId?: string }
): string | undefined {
const post = startup.chatLaunchPost;
if (opts.chatSurface && !opts.jobRunId && post) {
return buildChatEnvelope(
post.messageId,
startup.initialPrompt?.trim() ?? "",
post.attachmentLines
);
}
return buildStartupPrompt(
startup.initialPrompt,
startup.initialPins ?? [],
startup.initialMedia ?? []
);
}

/**
* Compose the first user-message-style prompt handed to the agent on
* launch — formats `initialPrompt`, `initialPins`, and `initialMedia` into
Expand All @@ -98,12 +153,7 @@ function stripModelArgs(args: string[]): string[] {
export function buildStartupPrompt(
initialPrompt: string | undefined,
initialPins: AgentPin[],
initialMedia: Array<{
fileName: string;
displayName: string;
source: string;
description: string | null;
}>
initialMedia: StartupMedia[]
): string | undefined {
const trimmedPrompt = initialPrompt?.trim() || "";
if (initialPins.length === 0 && initialMedia.length === 0) {
Expand Down Expand Up @@ -341,7 +391,14 @@ type BuildAgentCommandOptions = {
autoReview?: boolean;
trimmedGuidance?: boolean;
chatSurface?: boolean;
/**
* Raw first-turn inputs; `buildStartupTurn` composes them (envelope or
* plain startup prompt) using `chatSurface` and `jobRunId`.
*/
initialPrompt?: string;
initialPins?: AgentPin[];
initialMedia?: StartupMedia[];
chatLaunchPost?: ChatLaunchPost | null;
personalityPrompt?: string | null;
model?: string;
};
Expand All @@ -362,12 +419,24 @@ export function buildAgentCommand(
autoReview,
trimmedGuidance,
chatSurface,
initialPrompt,
initialPrompt: rawInitialPrompt,
initialPins,
initialMedia,
chatLaunchPost,
personalityPrompt,
model,
}: BuildAgentCommandOptions = {}
): string {
const agentId = agentIdFromSessionName(sessionName);
const initialPrompt = buildStartupTurn(
{
initialPrompt: rawInitialPrompt,
initialPins,
initialMedia,
chatLaunchPost,
},
{ chatSurface, jobRunId }
);
const launchGuidance = buildLaunchGuidance(agentId, {
agentType: type,
jobRunId,
Expand Down
59 changes: 58 additions & 1 deletion apps/server/src/chat/envelope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,56 @@
/**
* The envelope's own markers, line-anchored exactly as they are emitted:
* `--- DISPATCH CHAT (id: …) ---` and `--- END DISPATCH CHAT ---`. Leading
* whitespace and a longer run of dashes are matched too, because an agent
* reading the pane would treat those as the marker just the same.
*/
const ENVELOPE_MARKER_RE =
/^[ \t>]*-{3,}[ \t]*(?:END[ \t]+)?DISPATCH[ \t]+CHAT\b/i;

/**
* What a neutralized marker line is prefixed with. `> ` is deliberate: it
* reads as a quotation to a human and to the agent, it needs no exotic
* code points (nothing zero-width, nothing that a copy/paste would lose),
* and it moves the `---` off the start of the line so the line can no
* longer be read as a marker.
*/
export const ENVELOPE_MARKER_ESCAPE = "> ";

/**
* Neutralize any envelope marker inside caller-supplied text.
*
* The envelope is a plain-text frame around text Dispatch does not control:
* a user's Chat message, a launching agent's prompt, an attachment's pin
* label or code body. Without this, text containing
* `--- END DISPATCH CHAT ---` followed by a forged
* `--- DISPATCH CHAT (id: …) ---` block could close Dispatch's block and open
* one naming any message id, making the agent thread its reply onto a
* message the author has no claim to. Every line that matches the marker
* grammar is prefixed with `> `, so it survives visibly but cannot open or
* close a block.
*
* Applied inside `buildChatEnvelope`, which is the single place any text is
* wrapped — the composer path and the launch path therefore agree.
*/
export function escapeEnvelopeMarkers(text: string): string {
if (!text.includes("-")) return text;
// Split on every separator a pane, CLI or Markdown renderer may treat as a
// line break, not just \n: a lone CR (JSON and MCP strings carry them) or a
// Unicode line/paragraph separator would otherwise hide a forged marker
// from the match. Separators are normalized to \n on the way out, so the
// escaped text has one unambiguous line grammar.
let changed = false;
const lines = text.split(/\r\n|[\r\n\u2028\u2029]/).map((line) => {
if (!ENVELOPE_MARKER_RE.test(line)) return line;
changed = true;
return `${ENVELOPE_MARKER_ESCAPE}${line}`;
});
// Rejoining also normalizes separators, so return the joined form whenever
// the split saw anything other than plain \n.
const joined = lines.join("\n");
return changed || joined !== text ? joined : text;
}

/**
* The pane-injection envelope wrapping a user's Chat message. The trailing
* line tells the agent how to answer so the reply lands back in the Chat
Expand All @@ -6,6 +59,9 @@
* `attachmentLines` (one `- kind: …` line each) are listed after the text and
* before the closing marker so the agent can act on them. A blank text with
* attachments lists only the attachments.
*
* The whole body — text and attachment lines alike — passes through
* `escapeEnvelopeMarkers`, so nothing embedded here can forge a block.
*/
export function buildChatEnvelope(
messageId: string,
Expand All @@ -18,9 +74,10 @@ export function buildChatEnvelope(
if (body.length > 0) body.push("");
body.push("Attachments:", ...attachmentLines);
}
const safeBody = escapeEnvelopeMarkers(body.join("\n"));
return [
`--- DISPATCH CHAT (id: ${messageId}) ---`,
...body,
...(body.length > 0 ? [safeBody] : []),
"--- END DISPATCH CHAT ---",
`The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${messageId}"); terminal output alone will not reach them.`,
].join("\n");
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/chat/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,24 @@ async function listMediaEntries(
}>(
`SELECT id, file_name, size_bytes, description, created_at,
${AT_KEY_SQL} AS at_key
FROM media
WHERE agent_id = $1
FROM media m
WHERE m.agent_id = $1
-- Composer uploads (source 'user') already render as attachments on
-- the user's own post; listing them again would double them up.
AND source <> 'user' ${clause}
AND m.source <> 'user'
-- Same reasoning for a file an agent shared and then attached to a
-- post: the attachment is the richer rendering, so the standalone
-- media entry would be a duplicate. Checked against every message on
-- this agent, not just the ones on this page, so paging can't make a
-- file reappear.
AND NOT EXISTS (
SELECT 1
FROM agent_chat_messages c
WHERE c.agent_id = $1
AND c.attachments @> jsonb_build_array(
jsonb_build_object('type', 'file', 'mediaId', m.id)
)
) ${clause}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length}`,
params
Expand Down
Loading
Loading