diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts
index 3d220a77d..49c79a8fc 100644
--- a/apps/server/src/agents/manager.ts
+++ b/apps/server/src/agents/manager.ts
@@ -175,9 +175,13 @@ type CreateAgentInput = {
* what the CLI receives: `prompt` is the message as the person or launching
* agent wrote it (the MCP launch path wraps `initialPrompt` in a header the
* feed should not repeat); `links` are the raw startup URLs the route also
- * turned into url pins. Defaults to `initialPrompt` and no links.
+ * turned into url pins. Internal/generated startup prompts are deliberately
+ * omitted unless a caller explicitly supplies their user-authored context.
*/
- launchContext?: { prompt?: string; links?: string[] };
+ launchContext?: {
+ prompt?: string;
+ links?: string[];
+ };
initialPins?: AgentPin[];
initialFiles?: Array<{
fileName: string;
@@ -590,7 +594,7 @@ export class AgentManager {
return {
id: launchPostId,
agentId: p.id,
- text: input.launchContext?.prompt ?? input.initialPrompt,
+ text: input.launchContext?.prompt,
files: initialMedia.map((media) => ({ mediaId: media.mediaId })),
links: input.launchContext?.links ?? [],
pins: p.initialPins.map((pin) => ({
diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts
index 0b6266a07..35bd95e73 100644
--- a/apps/server/src/agents/tmux/command-builder.ts
+++ b/apps/server/src/agents/tmux/command-builder.ts
@@ -216,7 +216,7 @@ export function buildStartupPrompt(
* and attachment schema.
*/
export const CHAT_SURFACE_GUIDANCE_RULE =
- "Send user-facing replies and questions with dispatch_chat_post; use kind: question with options for finite choices. Terminal output remains in Console.";
+ "The user is reading Chat, not Console. Send every user-facing reply and question with dispatch_chat_post; use kind: question with options for finite choices.";
/**
* Build the numbered launch guidance text shared by all CLI agent types.
diff --git a/apps/server/src/chat/envelope.ts b/apps/server/src/chat/envelope.ts
index fcfbe4b35..425efe0b6 100644
--- a/apps/server/src/chat/envelope.ts
+++ b/apps/server/src/chat/envelope.ts
@@ -53,8 +53,8 @@ export function escapeEnvelopeMarkers(text: string): string {
/**
* 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
- * tab (docs/chat-surface-plan.md, "Injection envelope").
+ * line gives the minimum routing reminder needed to thread the reply back
+ * into Chat; the persistent launch guidance explains why.
*
* `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
@@ -79,7 +79,7 @@ export function buildChatEnvelope(
`--- DISPATCH CHAT (id: ${messageId}) ---`,
...(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.`,
+ `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`,
].join("\n");
}
diff --git a/apps/server/src/chat/feed.ts b/apps/server/src/chat/feed.ts
index 8e44f8ce8..95d94b4ec 100644
--- a/apps/server/src/chat/feed.ts
+++ b/apps/server/src/chat/feed.ts
@@ -223,17 +223,25 @@ async function listAgentMessageEntries(
recipient_agent_id: string;
sender_name: string;
recipient_name: string;
+ involves_child_agent: boolean;
content: string;
delivered: boolean | null;
created_at: Date;
at_key: string;
}>(
- `SELECT id, sender_agent_id, recipient_agent_id, sender_name,
- recipient_name, content, delivered, created_at,
+ `SELECT m.id, m.sender_agent_id, m.recipient_agent_id, m.sender_name,
+ m.recipient_name,
+ EXISTS (
+ SELECT 1
+ FROM agents child
+ WHERE child.id IN (m.sender_agent_id, m.recipient_agent_id)
+ AND child.parent_agent_id = $1
+ ) AS involves_child_agent,
+ m.content, m.delivered, m.created_at,
${AT_KEY_SQL} AS at_key
- FROM agent_messages
- WHERE (sender_agent_id = $1 OR recipient_agent_id = $1) ${clause}
- ORDER BY created_at DESC, id DESC
+ FROM agent_messages m
+ WHERE (m.sender_agent_id = $1 OR m.recipient_agent_id = $1) ${clause}
+ ORDER BY m.created_at DESC, m.id DESC
LIMIT $${params.length}`,
params
);
@@ -246,6 +254,7 @@ async function listAgentMessageEntries(
senderName: row.sender_name,
recipientAgentId: row.recipient_agent_id,
recipientName: row.recipient_name,
+ involvesChildAgent: row.involves_child_agent,
content: row.content,
delivered: row.delivered,
at: row.created_at.toISOString(),
diff --git a/apps/server/src/jobs/service.ts b/apps/server/src/jobs/service.ts
index 3e93d7c6e..8a6a372f1 100644
--- a/apps/server/src/jobs/service.ts
+++ b/apps/server/src/jobs/service.ts
@@ -200,9 +200,9 @@ export class JobService {
model: agentConfig.model ?? undefined,
cwd: job.directory,
agentArgs: buildAgentArgs(agentType, prompt, agentConfig.fullAccess),
- // The CLI receives the prompt through agentArgs; the Chat feed's
- // launch post needs it stated separately.
- launchContext: { prompt },
+ // The CLI receives generated job-run scaffolding through agentArgs;
+ // Chat shows only the user-authored job prompt.
+ launchContext: { prompt: resolvedPrompt },
fullAccess: agentConfig.fullAccess,
...templateWorktreeConfig(agentConfig),
jobRunId: run.id,
@@ -471,7 +471,7 @@ export class JobService {
prompt,
agentConfig.fullAccess
),
- launchContext: { prompt },
+ launchContext: { prompt: resolvedPrompt },
fullAccess: agentConfig.fullAccess,
...templateWorktreeConfig(agentConfig),
jobRunId: run.id,
diff --git a/apps/server/src/routes/agents/crud-routes.ts b/apps/server/src/routes/agents/crud-routes.ts
index 22a421207..ef2fc8993 100644
--- a/apps/server/src/routes/agents/crud-routes.ts
+++ b/apps/server/src/routes/agents/crud-routes.ts
@@ -323,7 +323,13 @@ export async function registerAgentCrudRoutes(
!isTerminalAgent && typeof body.initialPrompt === "string"
? body.initialPrompt.trim() || undefined
: undefined,
- launchContext: { links: !isTerminalAgent ? (startupLinks ?? []) : [] },
+ launchContext: {
+ prompt:
+ !isTerminalAgent && typeof body.initialPrompt === "string"
+ ? body.initialPrompt.trim() || undefined
+ : undefined,
+ links: !isTerminalAgent ? (startupLinks ?? []) : [],
+ },
initialPins: !isTerminalAgent ? startupPins : [],
initialFiles: !isTerminalAgent ? startupFiles : [],
});
diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts
index 096e26b8a..8355fb7ae 100644
--- a/apps/server/src/server/mcp-handlers.ts
+++ b/apps/server/src/server/mcp-handlers.ts
@@ -646,8 +646,9 @@ async function handleLaunchAgent(
launchedByAgentId: agentId,
cliSessionId,
initialPrompt: buildLaunchedAgentInitialPrompt(agentId, prompt, child),
- // The feed shows the prompt as the launcher wrote it, not the header.
- launchContext: { prompt },
+ // The feed shows the prompt as the launcher wrote it, not the rendered
+ // template instructions or the launch header.
+ launchContext: { prompt: input.prompt },
templateId: input.templateId,
});
diff --git a/apps/server/src/templates/service.ts b/apps/server/src/templates/service.ts
index f4e098e1e..2eb1c7260 100644
--- a/apps/server/src/templates/service.ts
+++ b/apps/server/src/templates/service.ts
@@ -222,7 +222,9 @@ export class TemplateService {
model: resolvedModel,
cwd,
initialPrompt: finalPrompt,
- launchContext: { links: !isTerminal ? (input.startupLinks ?? []) : [] },
+ launchContext: {
+ links: !isTerminal ? (input.startupLinks ?? []) : [],
+ },
fullAccess: !isTerminal && template.fullAccess,
...(isTerminal
? { useWorktree: false }
diff --git a/apps/server/test/chat-feed.test.ts b/apps/server/test/chat-feed.test.ts
index 1ced4095a..2a84a7392 100644
--- a/apps/server/test/chat-feed.test.ts
+++ b/apps/server/test/chat-feed.test.ts
@@ -15,15 +15,18 @@ let store: ChatStore;
const A = "agt_feed_a";
const OTHER = "agt_feed_other";
+const ARCHIVED_CHILD = "agt_feed_archived_child";
beforeAll(async () => {
pool = await setupTestDb();
await runTestMigrations();
store = new ChatStore(pool);
await pool.query(
- `INSERT INTO agents (id, name, cwd, status)
- VALUES ($1, 'Feed A', '/tmp', 'running'), ($2, 'Other', '/tmp', 'running')`,
- [A, OTHER]
+ `INSERT INTO agents (id, name, cwd, status, parent_agent_id, deleted_at)
+ VALUES ($1, 'Feed A', '/tmp', 'running', NULL, NULL),
+ ($2, 'Other', '/tmp', 'running', NULL, NULL),
+ ($3, 'Archived child', '/tmp', 'stopped', $1, NOW())`,
+ [A, OTHER, ARCHIVED_CHILD]
);
});
@@ -154,6 +157,32 @@ describe("composeChatFeed", () => {
expect(page3.entries.map((e) => e.type)).toEqual(["status"]);
});
+ it("marks both directions of archived child conversations", async () => {
+ await pool.query(
+ `INSERT INTO agent_messages
+ (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name,
+ content, delivered, created_at)
+ VALUES (gen_random_uuid(), $2, $1, 'Archived child', 'Feed A', 'in', true, $3),
+ (gen_random_uuid(), $1, $2, 'Feed A', 'Archived child', 'out', true, $4),
+ (gen_random_uuid(), $5, $1, 'Other', 'Feed A', 'peer', true, $4)`,
+ [A, ARCHIVED_CHILD, at(10), at(11), OTHER]
+ );
+
+ const messages = (await composeChatFeed(store, A)).entries.filter(
+ (entry) => entry.type === "agent_message"
+ );
+ expect(messages).toHaveLength(3);
+ expect(
+ messages
+ .map((entry) => [entry.content, entry.involvesChildAgent])
+ .sort(([left], [right]) => String(left).localeCompare(String(right)))
+ ).toEqual([
+ ["in", true],
+ ["out", true],
+ ["peer", false],
+ ]);
+ });
+
it("never drops or repeats rows that share a timestamp across sources", async () => {
// Nine rows at the same instant (three per source kind plus chat) with
// microsecond-identical created_at, paged two at a time.
diff --git a/apps/server/test/chat-routes.test.ts b/apps/server/test/chat-routes.test.ts
index 3fa698db9..1a6666d2e 100644
--- a/apps/server/test/chat-routes.test.ts
+++ b/apps/server/test/chat-routes.test.ts
@@ -566,7 +566,7 @@ describe("chat routes with a deliverable terminal", () => {
`--- DISPATCH CHAT (id: ${body.message.id}) ---`,
"please do X",
"--- 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: "${body.message.id}"); terminal output alone will not reach them.`,
+ `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${body.message.id}").`,
].join("\n")
);
expect(published).toEqual([
diff --git a/apps/server/test/chat-service.test.ts b/apps/server/test/chat-service.test.ts
index ba552a3e2..ea78a745b 100644
--- a/apps/server/test/chat-service.test.ts
+++ b/apps/server/test/chat-service.test.ts
@@ -713,7 +713,7 @@ describe("ChatService user workflows", () => {
"- pin: URL — http://x",
"- link: https://example.com/spec — Spec",
"--- 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: "${res.message.id}"); terminal output alone will not reach them.`,
+ `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${res.message.id}").`,
].join("\n")
);
});
diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts
index 43dd4a860..ae19f84dd 100644
--- a/apps/server/test/db/agent-manager.test.ts
+++ b/apps/server/test/db/agent-manager.test.ts
@@ -259,7 +259,10 @@ describe("AgentManager", () => {
cwd: "/tmp",
useWorktree: false,
initialPrompt: "Build the widget",
- launchContext: { links: ["https://example.com/spec"] },
+ launchContext: {
+ prompt: "Build the widget",
+ links: ["https://example.com/spec"],
+ },
initialPins: [
{
label: "example.com",
@@ -353,6 +356,7 @@ describe("AgentManager", () => {
useWorktree: false,
launchedByAgentId: parent.id,
initialPrompt: "Go",
+ launchContext: { prompt: "Go" },
});
expect((await launchPosts(independent.id))[0]).toMatchObject({
launched_by_agent_id: parent.id,
@@ -373,6 +377,7 @@ describe("AgentManager", () => {
useWorktree: false,
parentAgentId: parent.id,
initialPrompt: "Pretend I am the parent",
+ launchContext: { prompt: "Pretend I am the parent" },
});
expect((await launchPosts(child.id))[0]).toMatchObject({
author_kind: "user",
@@ -456,7 +461,10 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Build the widget",
- launchContext: { links: ["https://example.com/spec"] },
+ launchContext: {
+ prompt: "Build the widget",
+ links: ["https://example.com/spec"],
+ },
initialFiles: [
{
fileName: "brief.md",
@@ -501,6 +509,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Build the widget",
+ launchContext: { prompt: "Build the widget" },
});
expect(await launchPosts(agent.id)).toHaveLength(1);
const setupScript = await readFile(
@@ -511,6 +520,27 @@ describe("AgentManager", () => {
expect(setupScript).toContain("Build the widget");
});
+ it("keeps generated startup prompts out of Chat while retaining Chat guidance", async () => {
+ await withChatSurface(async () => {
+ const agent = await manager.createAgent({
+ cwd: "/tmp",
+ type: "claude",
+ useWorktree: false,
+ initialPrompt: "Internal launch instructions",
+ });
+ expect(await launchPosts(agent.id)).toEqual([]);
+ const setupScript = await readFile(
+ `/tmp/dispatch_setup_${agent.id}.sh`,
+ "utf-8"
+ );
+ expect(setupScript).toContain("Internal launch instructions");
+ expect(setupScript).toContain(
+ "The user is reading Chat, not Console."
+ );
+ expect(setupScript).not.toContain("--- DISPATCH CHAT");
+ });
+ });
+
it("starts the runtime without waiting on a write that never resolves", async () => {
const warn = vi.fn();
const stuckManager = new AgentManager(
@@ -586,6 +616,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Go",
+ launchContext: { prompt: "Go" },
});
expect(warn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -703,6 +734,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Go",
+ launchContext: { prompt: "Go" },
});
expect(warn).toHaveBeenCalledWith(
expect.objectContaining({ agentId: agent.id }),
diff --git a/apps/server/test/jobs-routes.test.ts b/apps/server/test/jobs-routes.test.ts
index 343d87b3a..28564c832 100644
--- a/apps/server/test/jobs-routes.test.ts
+++ b/apps/server/test/jobs-routes.test.ts
@@ -326,12 +326,9 @@ describe("POST /api/v1/jobs/run", () => {
origin: "launch",
attachments: [],
});
- // The post carries the same prompt the CLI receives through agentArgs:
- // the job header plus the job's own prompt.
- expect(posts.rows[0].text).toContain(`Run ID: ${body.runId}`);
- expect(posts.rows[0].text).toContain(
- "\nJob prompt:\nSweep the stale branches"
- );
+ // Chat shows only the user-authored prompt, not generated job lifecycle
+ // scaffolding (which still reaches the CLI through agentArgs).
+ expect(posts.rows[0].text).toBe("Sweep the stale branches");
await ctx.pool.query(
`UPDATE job_runs SET status = 'completed' WHERE id = $1`,
[body.runId]
diff --git a/apps/server/test/jobs/continuation.test.ts b/apps/server/test/jobs/continuation.test.ts
index 57011e552..0afd683ae 100644
--- a/apps/server/test/jobs/continuation.test.ts
+++ b/apps/server/test/jobs/continuation.test.ts
@@ -631,7 +631,7 @@ describe("continuation jobs", () => {
useWorktree: true,
baseBranch: "main",
launchContext: {
- prompt: expect.stringContaining("Continuation chain: chain-1"),
+ prompt: "Continue the work.",
},
// No stored branch name, so each iteration gets its own generated branch
// rather than colliding with the previous iteration's.
diff --git a/apps/server/test/jobs/service.test.ts b/apps/server/test/jobs/service.test.ts
index ad9b39137..b3acdc280 100644
--- a/apps/server/test/jobs/service.test.ts
+++ b/apps/server/test/jobs/service.test.ts
@@ -319,9 +319,9 @@ describe("JobService", () => {
expect.objectContaining({
jobRunId: result.runId,
name: `job-Rename_Test-${result.runId.slice(0, 8)}`,
- // The Chat launch post gets the job prompt the CLI receives.
+ // The Chat launch post gets only the user-authored job prompt.
launchContext: {
- prompt: expect.stringContaining(`Run ID: ${result.runId}`),
+ prompt: "Test prompt",
},
})
);
diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts
index 8a15e1d2a..5f80a5a0e 100644
--- a/apps/server/test/mcp-handlers.test.ts
+++ b/apps/server/test/mcp-handlers.test.ts
@@ -1316,7 +1316,7 @@ describe("createMcpHandlers", () => {
"--- DISPATCH CHAT (id: post-1) ---",
created.initialPrompt,
"--- 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: "post-1"); terminal output alone will not reach them.',
+ 'The user only sees Chat — reply with dispatch_chat_post (replyTo: "post-1").',
].join("\n")
);
expect(turn).toContain('You were launched by Dispatch agent "agt_test1"');
@@ -1561,6 +1561,9 @@ describe("createMcpHandlers", () => {
expect(initialPrompt).toContain("Build this idea:");
expect(initialPrompt).toContain("fix the launch bug");
expect(initialPrompt).not.toContain("{{D:");
+ expect(
+ deps.agentManager.createAgent.mock.calls[0][0].launchContext
+ ).toEqual({ prompt: "fix the launch bug" });
});
it("appends the caller's prompt to the template's prompt", async () => {
diff --git a/apps/server/test/template-routes.test.ts b/apps/server/test/template-routes.test.ts
index c4c0b5fad..f8e4b7a80 100644
--- a/apps/server/test/template-routes.test.ts
+++ b/apps/server/test/template-routes.test.ts
@@ -307,7 +307,8 @@ describe("POST /api/v1/templates/:id/launch", () => {
value: "https://example.com/spec",
}),
]);
- // ...but the launch post shows the URL once, as a link attachment.
+ // ...but the launch post shows the URL once, as a link attachment, while
+ // template runtime instructions stay out of Chat.
const posts = await ctx.pool.query(
`SELECT text, origin, attachments FROM agent_chat_messages
WHERE agent_id = $1`,
@@ -315,7 +316,7 @@ describe("POST /api/v1/templates/:id/launch", () => {
);
expect(posts.rows).toHaveLength(1);
expect(posts.rows[0]).toMatchObject({
- text: "Read the spec",
+ text: "",
origin: "launch",
attachments: [{ type: "link", url: "https://example.com/spec" }],
});
diff --git a/apps/server/test/templates/service.test.ts b/apps/server/test/templates/service.test.ts
index 92c9d82a4..105b57110 100644
--- a/apps/server/test/templates/service.test.ts
+++ b/apps/server/test/templates/service.test.ts
@@ -89,6 +89,7 @@ describe("TemplateService.launchTemplate", () => {
expect.objectContaining({
type: "codex",
initialPrompt: "Review src/app.tsx",
+ launchContext: { links: [] },
})
);
});
diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts
index c73348e35..d7112eb40 100644
--- a/apps/server/test/tmux-command-builder.test.ts
+++ b/apps/server/test/tmux-command-builder.test.ts
@@ -199,7 +199,7 @@ describe("buildStartupTurn — the Chat launch envelope", () => {
"- file: /media/agt_x/brief-2026.md (text/markdown, 300 B)",
"- pin: Ticket — DIS-42",
"--- 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: "${POST_ID}"); terminal output alone will not reach them.`,
+ `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${POST_ID}").`,
].join("\n")
);
});
@@ -1182,7 +1182,7 @@ describe("buildLaunchGuidance — trimmed variant", () => {
describe("buildLaunchGuidance — chat surface rule", () => {
const RULE =
- "Send user-facing replies and questions with dispatch_chat_post; use kind: question with options for finite choices. Terminal output remains in Console.";
+ "The user is reading Chat, not Console. Send every user-facing reply and question with dispatch_chat_post; use kind: question with options for finite choices.";
it("is absent by default and when the flag is off", () => {
expect(
diff --git a/apps/web/src/components/app/agent-pane.test.tsx b/apps/web/src/components/app/agent-pane.test.tsx
index 67c3ef9ac..99ae985ab 100644
--- a/apps/web/src/components/app/agent-pane.test.tsx
+++ b/apps/web/src/components/app/agent-pane.test.tsx
@@ -168,6 +168,24 @@ describe("AgentViewToggle", () => {
expect(screen.queryByTestId("agent-view-chat-unread")).toBeNull();
});
+ it("slides one compact indicator between views", () => {
+ const view = render(