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
177 changes: 174 additions & 3 deletions apps/server/src/agents/manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
readFile,
readdir,
rename,
rm,
stat,
unlink,
writeFile,
Expand DownExpand Up@@ -182,6 +183,14 @@ type CreateAgentInput = {
cliSessionId?: string;
jobRunId?: string;
initialPrompt?: string;
initialPins?: AgentPin[];
initialFiles?: Array<{
fileName: string;
originalName?: string;
buffer: Buffer;
source: "text" | "user";
description?: string | null;
}>;
};

type WorktreeCleanupMode = "auto" | "keep" | "force";
Expand DownExpand Up@@ -543,6 +552,7 @@ export class AgentManager {
const tmuxSession = this.toSessionName(id, name);
const mediaDir = path.join(this.config.mediaRoot, id);
await mkdir(mediaDir, { recursive: true });
const initialPins = input.initialPins ?? [];

const useWorktree = input.useWorktree !== false;
const createNewBranch = input.createNewBranch ?? true;
Expand DownExpand Up@@ -617,8 +627,8 @@ export class AgentManager {
const initialSetupPhase: SetupPhase = useWorktree ? "worktree" : "session";
await this.pool.query(
`
INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, updated_at)
VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())
INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, pins, updated_at)
VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, NOW())
`,
[
id,
Expand All@@ -638,9 +648,37 @@ export class AgentManager {
cliSessionId,
input.autoReview ?? false,
normalizedBaseBranch ?? null,
JSON.stringify(initialPins),
]
);

let initialMedia: Array<{
fileName: string;
displayName: string;
source: string;
description: string | null;
}> = [];
if (input.initialFiles && input.initialFiles.length > 0) {
try {
initialMedia = await this.seedInitialMedia(
id,
mediaDir,
input.initialFiles
);
} catch (error) {
await this.pool
.query("DELETE FROM agents WHERE id = $1", [id])
.catch(() => {});
await rm(mediaDir, { recursive: true, force: true }).catch(() => {});
throw error;
}
}
const startupPrompt = this.buildStartupPrompt(
input.initialPrompt,
initialPins,
initialMedia
);

if (this.config.agentRuntime === "inert") {
// Inert mode: no tmux, no setup script — do worktree synchronously and go straight to running
let effectiveCwd = originalCwd;
Expand DownExpand Up@@ -719,7 +757,7 @@ export class AgentManager {
jobRunId: input.jobRunId,
}),
!input.persona && !input.jobRunId && (input.autoReview ?? false),
input.initialPrompt
startupPrompt
);
const exitFile = `/tmp/dispatch_${tmuxSession}.exit`;

Expand DownExpand Up@@ -2547,6 +2585,139 @@ export class AgentManager {
return `${codexEnvPrefix} ${this.shellEscape(cliBin)} ${codexMcpFlags} ${escaped} ${this.shellEscape(startupPrompt)}`;
}

private buildStartupPrompt(
initialPrompt: string | undefined,
initialPins: AgentPin[],
initialMedia: Array<{
fileName: string;
displayName: string;
source: string;
description: string | null;
}>
): string | undefined {
const trimmedPrompt = initialPrompt?.trim() || "";
if (initialPins.length === 0 && initialMedia.length === 0) {
return trimmedPrompt || undefined;
}

const sections = [
"Startup context is attached to this session.",
"Inspect the provided pins and shared media before acting. Use Dispatch shared-media tools to access attached files; do not try to locate them by searching the filesystem by name.",
];

if (trimmedPrompt) {
sections.push(`Instructions:\n${trimmedPrompt}`);
}

if (initialPins.length > 0) {
sections.push(
[
"Links:",
...initialPins.map((pin) => {
try {
const hostname =
new URL(pin.value).hostname.replace(/^www\./, "") || "Link";
const numberedHostPattern = new RegExp(
`^${hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( \\d+)?$`,
"i"
);
return numberedHostPattern.test(pin.label)
? `- ${pin.value}`
: `- ${pin.label}: ${pin.value}`;
} catch {
return `- ${pin.value}`;
}
}),
].join("\n")
);
}

if (initialMedia.length > 0) {
sections.push(
[
"Attached files:",
...initialMedia.map((file) => {
const detail = file.description?.trim();
const suffix = detail ? ` — ${detail}` : "";
return `- ${file.displayName}${suffix} (available via dispatch shared media)`;
}),
].join("\n")
);
}

return sections.join("\n\n");
}

private async seedInitialMedia(
agentId: string,
mediaDir: string,
files: Array<{
fileName: string;
originalName?: string;
buffer: Buffer;
source: "text" | "user";
description?: string | null;
}>
): Promise<
Array<{
fileName: string;
displayName: string;
source: string;
description: string | null;
}>
> {
const createdAt = new Date();
const results: Array<{
fileName: string;
displayName: string;
source: string;
description: string | null;
}> = [];

for (const [index, file] of files.entries()) {
const timestampedFileName = this.timestampMediaFileName(
file.fileName,
createdAt,
index
);
await writeFile(path.join(mediaDir, timestampedFileName), file.buffer);
await this.pool.query(
`INSERT INTO media (agent_id, file_name, source, size_bytes, description)
VALUES ($1, $2, $3, $4, $5)`,
[
agentId,
timestampedFileName,
file.source,
file.buffer.length,
file.description ?? null,
]
);
results.push({
fileName: timestampedFileName,
displayName: file.originalName?.trim() || file.fileName,
source: file.source,
description: file.description ?? null,
});
}

return results;
}

private timestampMediaFileName(
fileName: string,
createdAt: Date,
index: number
): string {
const timestamp = createdAt
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "-")
.replace("Z", "");
const ext = path.extname(fileName);
const base = path.basename(fileName, ext);
return `${base}-${timestamp}-${index + 1}${ext}`;
}

private dispatchMcpUrl(agentId: string, jobRunId?: string): string {
const path = jobRunId
? `/api/mcp/jobs/${jobRunId}/${agentId}`
Expand Down
Loading
Loading