@getpaseo/server 0.1.97-beta.1 → 0.1.97-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
1
2
  export declare function resolveCreateAgentTitles(options: {
2
3
  configTitle?: string | null;
3
4
  initialPrompt?: string | null;
@@ -5,4 +6,5 @@ export declare function resolveCreateAgentTitles(options: {
5
6
  explicitTitle: string | null;
6
7
  provisionalTitle: string | null;
7
8
  };
9
+ export declare function resolveFirstAgentPromptTitle(firstAgentContext?: FirstAgentContext): string | null;
8
10
  //# sourceMappingURL=create-agent-title.d.ts.map
@@ -26,4 +26,9 @@ export function resolveCreateAgentTitles(options) {
26
26
  provisionalTitle,
27
27
  };
28
28
  }
29
+ export function resolveFirstAgentPromptTitle(firstAgentContext) {
30
+ return (resolveCreateAgentTitles({
31
+ initialPrompt: firstAgentContext?.prompt,
32
+ }).provisionalTitle ?? null);
33
+ }
29
34
  //# sourceMappingURL=create-agent-title.js.map
@@ -102,6 +102,7 @@ export declare class ProviderSnapshotManager {
102
102
  private reconcileSnapshotForRegistry;
103
103
  private warmUp;
104
104
  private refreshProviders;
105
+ private resolveProvidersToWarm;
105
106
  private clearCachedProviders;
106
107
  private loadProviders;
107
108
  private loadProvider;
@@ -43,31 +43,11 @@ export class ProviderSnapshotManager {
43
43
  }
44
44
  getSnapshot(cwd) {
45
45
  const resolvedCwd = resolveSnapshotCwd(cwd);
46
- const entries = this.snapshots.get(resolvedCwd);
47
- if (!entries) {
48
- const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
49
- void this.warmUp(resolvedCwd);
50
- return entriesToArray(loadingEntries);
46
+ const providersToWarm = this.resolveProvidersToWarm(resolvedCwd);
47
+ if (providersToWarm.length > 0) {
48
+ void this.warmUp(resolvedCwd, providersToWarm);
51
49
  }
52
- const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
53
- if (missingProviders.length > 0) {
54
- const loadingEntries = this.createLoadingEntries();
55
- for (const provider of missingProviders) {
56
- const loadingEntry = loadingEntries.get(provider);
57
- if (loadingEntry) {
58
- entries.set(provider, loadingEntry);
59
- }
60
- }
61
- void this.warmUp(resolvedCwd, missingProviders);
62
- }
63
- const providerLoads = this.providerLoads.get(resolvedCwd);
64
- const loadingProviders = Array.from(entries.values())
65
- .filter((entry) => entry.status === "loading" && !providerLoads?.has(entry.provider))
66
- .map((entry) => entry.provider);
67
- if (loadingProviders.length > 0) {
68
- void this.warmUp(resolvedCwd, loadingProviders);
69
- }
70
- return entriesToArray(entries);
50
+ return entriesToArray(this.getOrCreateSnapshot(resolvedCwd));
71
51
  }
72
52
  async refreshSnapshotForCwd(options) {
73
53
  const snapshotCwd = resolveSnapshotCwd(options.cwd);
@@ -91,17 +71,11 @@ export class ProviderSnapshotManager {
91
71
  if (options.providers && providers?.length === 0) {
92
72
  return;
93
73
  }
94
- const snapshot = this.snapshots.get(snapshotCwd);
95
- if (!snapshot) {
96
- this.resetSnapshotToLoading(snapshotCwd, providers);
97
- }
98
- else if (providers) {
99
- const missingProviders = providers.filter((provider) => !snapshot.has(provider));
100
- if (missingProviders.length > 0) {
101
- this.resetSnapshotToLoading(snapshotCwd, missingProviders);
102
- }
74
+ const providersToWarm = this.resolveProvidersToWarm(snapshotCwd, providers);
75
+ if (providersToWarm.length === 0) {
76
+ return;
103
77
  }
104
- await this.warmUp(snapshotCwd, providers);
78
+ await this.warmUp(snapshotCwd, providersToWarm);
105
79
  }
106
80
  async refresh(options) {
107
81
  await this.refreshSnapshotForCwd(options);
@@ -355,6 +329,19 @@ export class ProviderSnapshotManager {
355
329
  async refreshProviders(cwd, providers) {
356
330
  await this.loadProviders({ cwd, providers, force: true });
357
331
  }
332
+ resolveProvidersToWarm(cwd, providers) {
333
+ const providersToInspect = providers ?? this.getProviderIds();
334
+ const snapshot = this.snapshots.get(cwd);
335
+ if (!snapshot) {
336
+ this.resetSnapshotToLoading(cwd, providers);
337
+ return providersToInspect;
338
+ }
339
+ const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider));
340
+ if (missingProviders.length > 0) {
341
+ this.resetSnapshotToLoading(cwd, missingProviders);
342
+ }
343
+ return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading");
344
+ }
358
345
  clearCachedProviders(providers) {
359
346
  const providerSet = providers ? new Set(providers) : null;
360
347
  const loadingEntries = this.createLoadingEntries();
@@ -404,6 +391,10 @@ export class ProviderSnapshotManager {
404
391
  if (existingLoad && !options.force) {
405
392
  return existingLoad.promise;
406
393
  }
394
+ const existingEntry = this.snapshots.get(options.cwd)?.get(options.provider);
395
+ if (existingEntry && existingEntry.status !== "loading" && !options.force) {
396
+ return Promise.resolve();
397
+ }
407
398
  const load = {
408
399
  promise: Promise.resolve(),
409
400
  };
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto";
6
6
  import { hostname as getHostname } from "node:os";
7
7
  import path from "node:path";
8
8
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
10
9
  import { z } from "zod";
11
10
  import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
12
11
  function resolveBoundListenTarget(listenTarget, httpServer) {
@@ -587,8 +586,7 @@ export async function createPaseoDaemon(config, rootLogger) {
587
586
  let agentMcpBaseUrl = null;
588
587
  if (mcpEnabled) {
589
588
  const agentMcpRoute = "/mcp/agents";
590
- const agentMcpTransports = new Map();
591
- const createAgentMcpTransport = async (callerAgentId) => {
589
+ const createAgentMcpSession = async (callerAgentId) => {
592
590
  const agentMcpServer = await createAgentMcpServer({
593
591
  agentManager,
594
592
  agentStorage,
@@ -651,32 +649,24 @@ export async function createPaseoDaemon(config, rootLogger) {
651
649
  resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
652
650
  logger,
653
651
  });
652
+ // Stateless mode: each HTTP request builds a fresh server + transport that is
653
+ // torn down when the response closes, so no per-session state is retained between
654
+ // requests. The agent control plane only lists and calls tools, neither of which
655
+ // needs cross-request state, so sessions would only pin memory for the life of the
656
+ // daemon (agents that exit without a clean DELETE never get reaped).
654
657
  const transport = new StreamableHTTPServerTransport({
655
- sessionIdGenerator: () => randomUUID(),
656
- onsessioninitialized: (sessionId) => {
657
- agentMcpTransports.set(sessionId, transport);
658
- logger.debug({ sessionId }, "Agent MCP session initialized");
659
- },
660
- onsessionclosed: (sessionId) => {
661
- agentMcpTransports.delete(sessionId);
662
- logger.debug({ sessionId }, "Agent MCP session closed");
663
- },
658
+ sessionIdGenerator: undefined,
664
659
  // NOTE: We enforce a Vite-like host allowlist at the app/websocket layer.
665
660
  // StreamableHTTPServerTransport's built-in check requires exact Host header matches.
666
661
  enableDnsRebindingProtection: false,
667
662
  });
668
663
  Object.assign(transport, {
669
- onclose: () => {
670
- if (transport.sessionId) {
671
- agentMcpTransports.delete(transport.sessionId);
672
- }
673
- },
674
664
  onerror: (err) => {
675
665
  logger.error({ err }, "Agent MCP transport error");
676
666
  },
677
667
  });
678
668
  await agentMcpServer.connect(transport);
679
- return transport;
669
+ return { server: agentMcpServer, transport };
680
670
  };
681
671
  const runAgentMcpRequest = async (req, res) => {
682
672
  // This route is exempt from the global daemon-password middleware, so it
@@ -701,41 +691,33 @@ export async function createPaseoDaemon(config, rootLogger) {
701
691
  }, "Agent MCP request");
702
692
  }
703
693
  try {
704
- const sessionId = req.header("mcp-session-id");
705
- let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined;
706
- if (!transport) {
707
- if (req.method !== "POST") {
708
- res.status(400).json({
709
- jsonrpc: "2.0",
710
- error: {
711
- code: -32000,
712
- message: "Missing or invalid MCP session",
713
- },
714
- id: null,
715
- });
716
- return;
717
- }
718
- if (!isInitializeRequest(req.body)) {
719
- res.status(400).json({
720
- jsonrpc: "2.0",
721
- error: {
722
- code: -32000,
723
- message: "Initialization request expected",
724
- },
725
- id: null,
726
- });
727
- return;
728
- }
729
- const callerAgentIdRaw = req.query.callerAgentId;
730
- let callerAgentId;
731
- if (typeof callerAgentIdRaw === "string") {
732
- callerAgentId = callerAgentIdRaw;
733
- }
734
- else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") {
735
- callerAgentId = callerAgentIdRaw[0];
736
- }
737
- transport = await createAgentMcpTransport(callerAgentId);
694
+ // Stateless: GET (standalone SSE) and DELETE (session termination) have no
695
+ // meaning without sessions. The MCP client tolerates 405 on the GET stream
696
+ // and never issues a DELETE because it is never handed a session id.
697
+ if (req.method !== "POST") {
698
+ res.status(405).json({
699
+ jsonrpc: "2.0",
700
+ error: {
701
+ code: -32000,
702
+ message: "Method not allowed",
703
+ },
704
+ id: null,
705
+ });
706
+ return;
707
+ }
708
+ const callerAgentIdRaw = req.query.callerAgentId;
709
+ let callerAgentId;
710
+ if (typeof callerAgentIdRaw === "string") {
711
+ callerAgentId = callerAgentIdRaw;
738
712
  }
713
+ else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") {
714
+ callerAgentId = callerAgentIdRaw[0];
715
+ }
716
+ const { server, transport } = await createAgentMcpSession(callerAgentId);
717
+ res.on("close", () => {
718
+ void transport.close();
719
+ void server.close();
720
+ });
739
721
  await transport.handleRequest(req, res, req.body);
740
722
  }
741
723
  catch (err) {
@@ -5,6 +5,7 @@ import { createWorktreeCore, } from "./worktree-core.js";
5
5
  import { validateBranchSlug } from "../utils/worktree.js";
6
6
  import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
7
7
  import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, readPaseoWorktreeMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js";
8
+ import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
8
9
  import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
9
10
  export async function createPaseoWorktree(input, deps) {
10
11
  const createdWorktree = await createWorktreeCore(input, deps);
@@ -14,6 +15,7 @@ export async function createPaseoWorktree(input, deps) {
14
15
  projectId: input.projectId,
15
16
  repoRoot: createdWorktree.repoRoot,
16
17
  worktree: createdWorktree.worktree,
18
+ title: resolveFirstAgentPromptTitle(input.firstAgentContext),
17
19
  deps,
18
20
  });
19
21
  deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
@@ -140,6 +142,7 @@ async function upsertWorkspaceForWorktree(options) {
140
142
  kind: "worktree",
141
143
  displayName: options.worktree.branchName || normalizedCwd,
142
144
  branch: options.worktree.branchName || null,
145
+ title: options.title ?? null,
143
146
  createdAt: now,
144
147
  updatedAt: now,
145
148
  archivedAt: null,
@@ -345,6 +345,7 @@ export declare class Session {
345
345
  private scheduleAutoNameWorkspaceBranchForFirstAgent;
346
346
  private maybeAutoNameWorkspaceBranchForFirstAgent;
347
347
  private applyGeneratedWorkspaceTitle;
348
+ private writeInitialWorkspaceTitleIfUntitled;
348
349
  private generateWorkspaceTitleFromContext;
349
350
  private maybeAutoNameDirectoryWorkspaceTitle;
350
351
  private scheduleAutoNameLocalWorkspaceTitleForFirstAgent;
@@ -479,6 +480,7 @@ export declare class Session {
479
480
  private bufferOrEmitWorkspaceUpdate;
480
481
  private flushBootstrappedWorkspaceUpdates;
481
482
  private findOrCreateWorkspaceForDirectory;
483
+ private resolveOrCreateWorkspaceIdForCreateAgent;
482
484
  private createWorkspaceForDirectory;
483
485
  private reclassifyOrUnarchiveWorkspaceForDirectory;
484
486
  private resolveProjectRecordForPlacement;
@@ -23,7 +23,7 @@ import { createVoiceTurnController, } from "./voice/voice-turn-controller.js";
23
23
  import { buildConfigOverrides, extractTimestamps, isStoredAgentProviderAvailable, toAgentPersistenceHandle, } from "./persistence-hooks.js";
24
24
  import { ensureAgentLoaded } from "./agent/agent-loading.js";
25
25
  import { formatSystemNotificationPrompt, sendPromptToAgent, waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js";
26
- import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
26
+ import { resolveCreateAgentTitles, resolveFirstAgentPromptTitle, } from "./agent/create-agent-title.js";
27
27
  import { respondToAgentPermission } from "./agent/permission-response.js";
28
28
  import { experimental_createMCPClient } from "ai";
29
29
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -2169,6 +2169,7 @@ export class Session {
2169
2169
  ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
2170
2170
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
2171
2171
  };
2172
+ const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext);
2172
2173
  const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
2173
2174
  cwd: config.cwd,
2174
2175
  target: worktree,
@@ -2179,12 +2180,12 @@ export class Session {
2179
2180
  const createAgentConfig = createdWorktree
2180
2181
  ? { ...config, cwd: createdWorktree.worktree.worktreePath }
2181
2182
  : config;
2182
- // Ownership comes from an explicit id (worktree or request). An agent
2183
- // created with no explicit workspace mints a fresh one — we never resolve
2184
- // an existing workspace by cwd, because many workspaces may share a cwd.
2185
- const workspaceId = createdWorktree?.workspace.workspaceId ??
2186
- msg.workspaceId ??
2187
- (await this.createWorkspaceForDirectory(createAgentConfig.cwd)).workspaceId;
2183
+ const workspaceId = await this.resolveOrCreateWorkspaceIdForCreateAgent({
2184
+ createdWorktree,
2185
+ requestedWorkspaceId: msg.workspaceId,
2186
+ cwd: createAgentConfig.cwd,
2187
+ initialTitle: workspacePromptTitle,
2188
+ });
2188
2189
  const { snapshot, liveSnapshot } = await createAgentCommand({
2189
2190
  agentManager: this.agentManager,
2190
2191
  agentStorage: this.agentStorage,
@@ -2210,6 +2211,9 @@ export class Session {
2210
2211
  buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
2211
2212
  });
2212
2213
  createdAgentId = snapshot.id;
2214
+ if (!createdWorktree && msg.workspaceId) {
2215
+ await this.writeInitialWorkspaceTitleIfUntitled(workspaceId, workspacePromptTitle);
2216
+ }
2213
2217
  await this.forwardAgentUpdate(snapshot);
2214
2218
  if (!createdWorktree && trimmedPrompt) {
2215
2219
  await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
@@ -2585,26 +2589,43 @@ export class Session {
2585
2589
  await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, {
2586
2590
  title: generatedTitle,
2587
2591
  branch: result.branchName,
2592
+ promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
2588
2593
  });
2589
2594
  await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
2590
2595
  await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
2591
2596
  }
2592
- // applyGeneratedWorkspaceTitle fills the generated title only while the
2593
- // workspace is still untitled. It re-reads the current record from the
2594
- // registry so concurrent upserts that happened after workspace creation are
2595
- // not clobbered, while still persisting branch metadata from the rename path.
2597
+ // Generated names may replace the prompt title set at creation, but not a user
2598
+ // rename that landed while the async generator was running.
2596
2599
  async applyGeneratedWorkspaceTitle(workspaceId, input) {
2597
2600
  const current = await this.workspaceRegistry.get(workspaceId);
2598
2601
  if (!current) {
2599
2602
  return;
2600
2603
  }
2604
+ let title = current.title;
2605
+ if (!title || (input.promptTitle && title === input.promptTitle)) {
2606
+ title = input.title;
2607
+ }
2601
2608
  await this.workspaceRegistry.upsert({
2602
2609
  ...current,
2603
- title: current.title || input.title,
2610
+ title,
2604
2611
  ...(input.branch ? { branch: input.branch } : {}),
2605
2612
  updatedAt: new Date().toISOString(),
2606
2613
  });
2607
2614
  }
2615
+ async writeInitialWorkspaceTitleIfUntitled(workspaceId, title) {
2616
+ if (!title) {
2617
+ return;
2618
+ }
2619
+ const current = await this.workspaceRegistry.get(workspaceId);
2620
+ if (!current || current.title) {
2621
+ return;
2622
+ }
2623
+ await this.workspaceRegistry.upsert({
2624
+ ...current,
2625
+ title,
2626
+ updatedAt: new Date().toISOString(),
2627
+ });
2628
+ }
2608
2629
  // Wraps the injected workspace-name generator for a directory workspace.
2609
2630
  async generateWorkspaceTitleFromContext(input) {
2610
2631
  return this.generateWorkspaceName({
@@ -2619,8 +2640,7 @@ export class Session {
2619
2640
  });
2620
2641
  }
2621
2642
  // Generates a human title for a directory workspace from the firstAgentContext
2622
- // prompt and writes it as displayName. No branch rename — directory workspaces
2623
- // have no worktree git state.
2643
+ // prompt. No branch rename — directory workspaces have no worktree git state.
2624
2644
  // TODO(K7): same-dir directory-workspace display disambiguation not yet implemented.
2625
2645
  async maybeAutoNameDirectoryWorkspaceTitle(input) {
2626
2646
  const generated = await this.generateWorkspaceTitleFromContext({
@@ -2633,7 +2653,10 @@ export class Session {
2633
2653
  }
2634
2654
  // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing.
2635
2655
  // Directory workspaces have no branch — write only the title.
2636
- await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title });
2656
+ await this.applyGeneratedWorkspaceTitle(input.workspaceId, {
2657
+ title,
2658
+ promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
2659
+ });
2637
2660
  await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
2638
2661
  }
2639
2662
  async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input) {
@@ -3011,8 +3034,13 @@ export class Session {
3011
3034
  const prompt = await buildMetadataPrompt({
3012
3035
  cwd,
3013
3036
  workspaceGitService: this.workspaceGitService,
3014
- configKey: "commitMessage",
3015
- before: "Write a concise git commit message for the changes below.",
3037
+ contract: "Write a concise git commit message for the changes below.",
3038
+ styles: [
3039
+ {
3040
+ configKey: "commitMessage",
3041
+ default: "Concise, imperative mood, no trailing period.",
3042
+ },
3043
+ ],
3016
3044
  after: [
3017
3045
  "Return JSON only with a single field 'message'.",
3018
3046
  "",
@@ -3079,8 +3107,13 @@ export class Session {
3079
3107
  const prompt = await buildMetadataPrompt({
3080
3108
  cwd,
3081
3109
  workspaceGitService: this.workspaceGitService,
3082
- configKey: "pullRequest",
3083
- before: "Write a pull request title and body for the changes below.",
3110
+ contract: "Write a pull request title and body for the changes below.",
3111
+ styles: [
3112
+ {
3113
+ configKey: "pullRequest",
3114
+ default: "Clear, descriptive title; body explaining what changed and why.",
3115
+ },
3116
+ ],
3084
3117
  after: [
3085
3118
  "Return JSON only with fields 'title' and 'body'.",
3086
3119
  "",
@@ -5221,7 +5254,16 @@ export class Session {
5221
5254
  }
5222
5255
  return this.createWorkspaceForDirectory(normalizedCwd);
5223
5256
  }
5224
- async createWorkspaceForDirectory(cwd) {
5257
+ async resolveOrCreateWorkspaceIdForCreateAgent(input) {
5258
+ if (input.createdWorktree) {
5259
+ return input.createdWorktree.workspace.workspaceId;
5260
+ }
5261
+ if (input.requestedWorkspaceId) {
5262
+ return input.requestedWorkspaceId;
5263
+ }
5264
+ return (await this.createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
5265
+ }
5266
+ async createWorkspaceForDirectory(cwd, title) {
5225
5267
  const checkout = await this.workspaceGitService.getCheckout(cwd);
5226
5268
  const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
5227
5269
  const timestamp = new Date().toISOString();
@@ -5236,6 +5278,7 @@ export class Session {
5236
5278
  cwd,
5237
5279
  kind: membership.workspaceKind,
5238
5280
  displayName: membership.workspaceDisplayName,
5281
+ title: title ?? null,
5239
5282
  createdAt: timestamp,
5240
5283
  updatedAt: timestamp,
5241
5284
  });
@@ -5765,7 +5808,9 @@ export class Session {
5765
5808
  });
5766
5809
  return;
5767
5810
  }
5768
- const workspace = await createLocalCheckoutWorkspace({ cwd, title: request.title ?? null }, {
5811
+ const explicitTitle = request.title?.trim() || null;
5812
+ const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
5813
+ const workspace = await createLocalCheckoutWorkspace({ cwd, title: explicitTitle ?? promptTitle }, {
5769
5814
  projectRegistry: this.projectRegistry,
5770
5815
  workspaceRegistry: this.workspaceRegistry,
5771
5816
  workspaceGitService: this.workspaceGitService,
@@ -173,9 +173,9 @@ export function createPersistedWorkspaceRecord(input) {
173
173
  archivedAt: input.archivedAt ?? null,
174
174
  });
175
175
  }
176
- // The single workspace-name rule: the user-set title always wins; otherwise fall
177
- // back to the freshest available derived display name (a live branch snapshot when
178
- // the caller has one, the persisted displayName otherwise).
176
+ // The single workspace-name rule: the title always wins; otherwise fall back to
177
+ // the freshest available derived display name (a live branch snapshot when the
178
+ // caller has one, the persisted displayName otherwise).
179
179
  export function resolveWorkspaceName(input) {
180
180
  return input.title ?? input.derivedDisplayName;
181
181
  }
@@ -11,14 +11,30 @@ async function buildPrompt(seed, options) {
11
11
  return buildMetadataPrompt({
12
12
  cwd: options.cwd,
13
13
  workspaceGitService: options.workspaceGitService,
14
- configKey: "branchName",
15
- before: [
16
- "Generate a git branch name for a coding agent based on the user prompt and attachments.",
17
- "Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters).",
18
- "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
19
- "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
14
+ contract: [
15
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
16
+ "The branch must be a valid git ref: lowercase letters, numbers, hyphens, and slashes only, with no spaces, no uppercase, no leading or trailing hyphen, and no consecutive hyphens.",
20
17
  "The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
21
18
  ].join("\n"),
19
+ styles: [
20
+ {
21
+ configKey: "title",
22
+ label: "Title style",
23
+ default: [
24
+ "A terse, task-shaped label naming what the task is about (sentence case, max 80 characters).",
25
+ "Aim for about 4 words. Go longer only when the task genuinely needs it; most titles must stay short.",
26
+ "Do not start with a generic 'do' verb (Fix, Add, Implement, Diagnose, Update, Change, Create, Set, Make) — every task is implicitly one of these, so the verb is noise. Name the thing instead.",
27
+ "Keep a verb only when it states the specific operation (Swap, Split, Extract, Rename, Merge, Inline).",
28
+ 'Good titles: "Swap sidebar history icon", "Composer keyboard shift", "Agent auto-titling", "Worktree selection memory", "Split browser pane".',
29
+ 'Bad titles: "Fix composer pushed up by keyboard in workspace", "Diagnose auto-titling still happening for agents", "Change sidebar history icon from clock to history icon".',
30
+ ].join("\n"),
31
+ },
32
+ {
33
+ configKey: "branchName",
34
+ label: "Branch style",
35
+ default: "A short, descriptive slug — a few lowercase words joined by hyphens.",
36
+ },
37
+ ],
22
38
  after: "Return JSON only with fields 'title' and 'branch'.",
23
39
  trailing: `User context:\n${seed}`,
24
40
  });
@@ -1,11 +1,16 @@
1
- export type MetadataConfigKey = "branchName" | "commitMessage" | "pullRequest";
1
+ export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest";
2
2
  export interface RepoRootResolver {
3
3
  resolveRepoRoot: (cwd: string) => Promise<string>;
4
4
  }
5
+ export interface MetadataStyleSection {
6
+ configKey: MetadataConfigKey;
7
+ default: string;
8
+ label?: string;
9
+ }
5
10
  export interface BuildMetadataPromptOptions {
6
11
  cwd: string;
7
- configKey: MetadataConfigKey;
8
- before: string;
12
+ contract: string;
13
+ styles: MetadataStyleSection[];
9
14
  after: string;
10
15
  trailing?: string;
11
16
  workspaceGitService?: RepoRootResolver;
@@ -1,22 +1,23 @@
1
1
  import { readPaseoConfigJson } from "./paseo-config-file.js";
2
- import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
3
- import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
2
+ import { PaseoConfigSchema, } from "@getpaseo/protocol/paseo-config-schema";
4
3
  export async function buildMetadataPrompt(options) {
5
- const instructions = await readProjectMetadataInstructions(options);
6
- const head = isNonEmptyString(instructions)
7
- ? wrapWithUserInstructions(options.before, instructions, options.after)
8
- : `${options.before}\n${options.after}`;
4
+ const overrides = await readProjectMetadataOverrides(options);
5
+ const styleBlocks = options.styles.map((section) => renderStyleSection(section, overrides?.[section.configKey]?.instructions));
6
+ const head = [options.contract, ...styleBlocks, options.after].join("\n\n");
9
7
  return options.trailing ? `${head}\n\n${options.trailing}` : head;
10
8
  }
11
- async function readProjectMetadataInstructions(options) {
9
+ function renderStyleSection(section, override) {
10
+ const body = isNonEmptyString(override) ? override.trim() : section.default;
11
+ return section.label ? `${section.label}:\n${body}` : body;
12
+ }
13
+ async function readProjectMetadataOverrides(options) {
12
14
  if (!options.workspaceGitService) {
13
15
  return undefined;
14
16
  }
15
17
  try {
16
18
  const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
17
19
  const json = readPaseoConfigJson(repoRoot);
18
- const config = PaseoConfigSchema.parse(json);
19
- return config.metadataGeneration?.[options.configKey]?.instructions;
20
+ return PaseoConfigSchema.parse(json).metadataGeneration;
20
21
  }
21
22
  catch {
22
23
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpaseo/server",
3
- "version": "0.1.97-beta.1",
3
+ "version": "0.1.97-beta.3",
4
4
  "description": "Paseo backend server",
5
5
  "files": [
6
6
  "dist/server",
@@ -64,10 +64,10 @@
64
64
  "dependencies": {
65
65
  "@agentclientprotocol/sdk": "^0.17.1",
66
66
  "@anthropic-ai/claude-agent-sdk": "^0.2.133",
67
- "@getpaseo/client": "0.1.97-beta.1",
68
- "@getpaseo/highlight": "0.1.97-beta.1",
69
- "@getpaseo/protocol": "0.1.97-beta.1",
70
- "@getpaseo/relay": "0.1.97-beta.1",
67
+ "@getpaseo/client": "0.1.97-beta.3",
68
+ "@getpaseo/highlight": "0.1.97-beta.3",
69
+ "@getpaseo/protocol": "0.1.97-beta.3",
70
+ "@getpaseo/relay": "0.1.97-beta.3",
71
71
  "@isaacs/ttlcache": "^2.1.4",
72
72
  "@modelcontextprotocol/sdk": "^1.20.1",
73
73
  "@opencode-ai/sdk": "1.14.46",
@@ -1,2 +0,0 @@
1
- export declare function wrapWithUserInstructions(beforeBlock: string, instructions: string, afterBlock: string): string;
2
- //# sourceMappingURL=wrap-user-instructions.d.ts.map
@@ -1,13 +0,0 @@
1
- const USER_INSTRUCTIONS_NOTICE = "The instructions below are provided by the project owner and override the guidelines above where they conflict.";
2
- export function wrapWithUserInstructions(beforeBlock, instructions, afterBlock) {
3
- return `${beforeBlock}
4
-
5
- <user-instructions>
6
- ${USER_INSTRUCTIONS_NOTICE}
7
-
8
- ${instructions}
9
- </user-instructions>
10
-
11
- ${afterBlock}`;
12
- }
13
- //# sourceMappingURL=wrap-user-instructions.js.map