@hyperdrive.bot/paseo-server 0.3.39 → 0.3.41

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/server/server/agent/agent-manager.js +15 -0
  3. package/dist/server/server/agent/agent-projections.js +3 -0
  4. package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
  5. package/dist/server/server/agent/agent-storage.d.ts +2 -1
  6. package/dist/server/server/agent/agent-storage.js +4 -0
  7. package/dist/server/server/agent/mcp-shared.js +5 -2
  8. package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
  9. package/dist/server/server/agent/providers/claude/agent.js +74 -0
  10. package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
  11. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +6 -0
  12. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
  13. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
  14. package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
  15. package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
  16. package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +33 -1
  17. package/dist/server/server/agent/providers/claude/transport/pty-query.js +110 -5
  18. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +32 -0
  19. package/dist/server/server/agent/providers/claude/transport/pty.js +69 -5
  20. package/dist/server/server/agent/providers/claude/transport/sdk.d.ts +2 -0
  21. package/dist/server/server/agent/providers/claude/transport/sdk.js +4 -0
  22. package/dist/server/server/agent/providers/claude/transport/types.d.ts +8 -0
  23. package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
  24. package/dist/server/server/agent/tools/paseo-tools.js +213 -38
  25. package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
  26. package/dist/server/server/agent/tools/read-only-surface.js +1 -0
  27. package/dist/server/server/persistence-hooks.js +2 -0
  28. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.d.ts +13 -1
  29. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.js +27 -3
  30. package/dist/server/server/session.js +6 -2
  31. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js → index-cb251ddad56c08c3021036af3a43fc0f.js} +5 -5
  32. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
  33. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
  34. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.br → index-cb251ddad56c08c3021036af3a43fc0f.js.map.br} +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.gz → index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz} +0 -0
  36. package/dist/server/web-ui/index.html +1 -1
  37. package/dist/server/web-ui/index.html.br +0 -0
  38. package/dist/server/web-ui/index.html.gz +0 -0
  39. package/package.json +6 -6
  40. package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.br +0 -0
  41. package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.gz +0 -0
@@ -141,6 +141,51 @@ function resolveScheduleUpdateCadence(input) {
141
141
  }
142
142
  return undefined;
143
143
  }
144
+ // A duration ("90m", "2h30m", "45" seconds) rather than a timestamp. Checked
145
+ // first so `at` can accept both without Date parsing guessing at bare numbers.
146
+ const SCHEDULE_DURATION_ONLY_PATTERN = /^(?:\d+|(?:\d+[smhd])+)$/;
147
+ function resolveScheduleAtInstant(at, now) {
148
+ if (SCHEDULE_DURATION_ONLY_PATTERN.test(at)) {
149
+ return new Date(now.getTime() + parseDurationString(at));
150
+ }
151
+ const parsed = new Date(at);
152
+ if (Number.isNaN(parsed.getTime())) {
153
+ throw new Error(`Invalid at value: ${at}. Use an ISO 8601 instant (2026-08-21T21:30:00-03:00) or a duration from now (90m).`);
154
+ }
155
+ return parsed;
156
+ }
157
+ export function resolveScheduleCreateCadence(input, now) {
158
+ const at = normalizeScheduleCadenceArg(input.at);
159
+ const every = normalizeScheduleCadenceArg(input.every);
160
+ const cron = normalizeScheduleCadenceArg(input.cron);
161
+ const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
162
+ const provided = [at, every, cron].filter((value) => value !== undefined);
163
+ if (provided.length !== 1) {
164
+ throw new Error("Specify exactly one of at, every, or cron");
165
+ }
166
+ if (timeZone !== undefined && cron === undefined) {
167
+ throw new Error("timezone can only be used with cron");
168
+ }
169
+ if (at !== undefined) {
170
+ const instant = resolveScheduleAtInstant(at, now);
171
+ const everyMs = instant.getTime() - now.getTime();
172
+ if (everyMs <= 0) {
173
+ throw new Error(`at must be in the future: ${instant.toISOString()} is not after ${now.toISOString()}`);
174
+ }
175
+ return { cadence: { type: "every", everyMs }, oneOff: true };
176
+ }
177
+ if (every !== undefined) {
178
+ return { cadence: { type: "every", everyMs: parseDurationString(every) }, oneOff: false };
179
+ }
180
+ return {
181
+ cadence: {
182
+ type: "cron",
183
+ expression: cron,
184
+ ...(timeZone !== undefined ? { timezone: timeZone } : {}),
185
+ },
186
+ oneOff: false,
187
+ };
188
+ }
144
189
  function resolveScheduleUpdateExpiresAt(input) {
145
190
  if (input.expiresIn !== undefined && input.clearExpires) {
146
191
  throw new Error("Specify at most one of expiresIn or clearExpires");
@@ -277,18 +322,6 @@ export function createPaseoToolCatalog(options) {
277
322
  return tool.handler(await parseToolInput(tool, input), context);
278
323
  },
279
324
  });
280
- const buildCronScheduleCadence = (input) => {
281
- const expression = input.cron?.trim() ?? "";
282
- if (!expression) {
283
- throw new Error("cron is required");
284
- }
285
- const timezone = normalizeScheduleTimeZoneArg(input.timezone);
286
- return {
287
- type: "cron",
288
- expression,
289
- ...(timezone !== undefined ? { timezone } : {}),
290
- };
291
- };
292
325
  const buildScheduleExpiry = (expiresIn) => {
293
326
  return expiresIn === undefined
294
327
  ? undefined
@@ -427,6 +460,59 @@ export function createPaseoToolCatalog(options) {
427
460
  },
428
461
  };
429
462
  };
463
+ // A schedule bound to an agent that is gone can never fire: the startup sweep
464
+ // completes it on sight. Fail loudly at create time instead of handing back a
465
+ // schedule that reads active and is already dead.
466
+ const assertScheduleTargetAgentExists = async (agentId) => {
467
+ if (agentManager.getAgent(agentId)) {
468
+ return;
469
+ }
470
+ const record = await agentStorage.get(agentId);
471
+ if (!record || record.internal) {
472
+ throw new Error(`Agent ${agentId} not found`);
473
+ }
474
+ if (record.archivedAt) {
475
+ throw new Error(`Agent ${agentId} is archived and cannot be a schedule target`);
476
+ }
477
+ };
478
+ const resolveScheduleCreateTarget = async (params) => {
479
+ const target = params.target?.trim();
480
+ // Default stays new-agent so callers written against the narrower schema
481
+ // keep the behaviour they had before target existed.
482
+ if (!target || target === "new-agent") {
483
+ return resolveNewAgentScheduleTarget({
484
+ ...(params.provider !== undefined ? { provider: params.provider } : {}),
485
+ ...(params.cwd !== undefined ? { cwd: params.cwd } : {}),
486
+ });
487
+ }
488
+ if (params.provider !== undefined || params.cwd !== undefined) {
489
+ throw new Error("provider and cwd can only be used with target new-agent");
490
+ }
491
+ if (target === "self") {
492
+ if (!callerAgentId) {
493
+ throw new Error('target "self" requires an agent-scoped session');
494
+ }
495
+ resolveCallerAgent();
496
+ return { type: "agent", agentId: callerAgentId };
497
+ }
498
+ await assertScheduleTargetAgentExists(target);
499
+ return { type: "agent", agentId: target };
500
+ };
501
+ // Recurring creates go through createOrReplace so an agent re-registering the
502
+ // same standing schedule refreshes it instead of minting a twin. A one-off must
503
+ // NOT: createOrReplace carries the existing schedule's `runs` forward, and
504
+ // shouldCompleteSchedule counts them against the incoming maxRuns before tick
505
+ // looks at nextRunAt, so replacing a schedule that has already run with
506
+ // maxRuns 1 completes it on the next tick without ever firing. There is also
507
+ // nothing to make idempotent about a one-off: two calls mean two reminders.
508
+ // Covered by schedule/service.test.ts "replacing a schedule that has run with
509
+ // maxRuns 1 buries it without firing".
510
+ const persistNewSchedule = async (input, oneOff) => {
511
+ if (!scheduleService) {
512
+ throw new Error("Schedule service is not configured");
513
+ }
514
+ return oneOff ? scheduleService.create(input) : scheduleService.createOrReplace(input);
515
+ };
430
516
  const ProviderModelInputSchema = AgentProviderEnum.trim()
431
517
  .refine((value) => value.includes("/"), {
432
518
  message: "provider must be provider/model, for example codex/gpt-5.4",
@@ -1609,39 +1695,80 @@ export function createPaseoToolCatalog(options) {
1609
1695
  });
1610
1696
  registerTool("create_schedule", {
1611
1697
  title: "Create schedule",
1612
- description: "Create a recurring schedule that starts a new agent on a cron cadence.",
1698
+ description: "Schedule a prompt for later: once at a specific time (at), on a repeating interval (every), or on a cron cadence (cron). The prompt can start a fresh agent, come back to you (target self), or go to another agent by id. Prefer this over an in-session loop whenever the work must survive this session ending, since schedules are owned by the daemon.",
1613
1699
  inputSchema: {
1614
1700
  prompt: z.string().trim().min(1, "prompt is required"),
1615
- cron: z.string().trim().min(1, "cron is required"),
1701
+ at: z
1702
+ .string()
1703
+ .trim()
1704
+ .min(1)
1705
+ .optional()
1706
+ .describe('One-off delivery time. An ISO 8601 instant ("2026-08-21T21:30:00-03:00") or a duration from now ("90m", "2h30m"). Runs exactly once. Mutually exclusive with every and cron.'),
1707
+ every: z
1708
+ .string()
1709
+ .trim()
1710
+ .min(1)
1711
+ .optional()
1712
+ .describe('Repeating interval, for example "5m", "1h", "2h30m", "1d". Mutually exclusive with at and cron.'),
1713
+ cron: z
1714
+ .string()
1715
+ .trim()
1716
+ .min(1)
1717
+ .optional()
1718
+ .describe('Repeating cron expression, for example "0 9 * * 1-5". Mutually exclusive with at and every.'),
1616
1719
  timezone: z
1617
1720
  .string()
1618
1721
  .trim()
1619
1722
  .min(1)
1620
1723
  .optional()
1621
- .describe("IANA time zone for the cron cadence. For example: America/New_York."),
1622
- name: z.string().optional(),
1623
- provider: AgentProviderEnum.optional().describe("Provider, or provider/model (for example: codex or codex/gpt-5.4)."),
1624
- cwd: z.string().optional(),
1724
+ .describe("IANA time zone for the cron cadence. Requires cron. For example: America/New_York."),
1725
+ name: z
1726
+ .string()
1727
+ .optional()
1728
+ .describe("Reusing a name with the same target refreshes that schedule in place instead of creating a second one."),
1729
+ target: z
1730
+ .string()
1731
+ .trim()
1732
+ .min(1)
1733
+ .optional()
1734
+ .describe('Who receives the prompt: "new-agent" (default) starts a fresh agent each run, "self" delivers to you, or an agent id delivers to that agent.'),
1735
+ provider: AgentProviderEnum.optional().describe("Provider, or provider/model (for example: codex or codex/gpt-5.4). Only valid with target new-agent."),
1736
+ cwd: z.string().optional().describe("Working directory. Only valid with target new-agent."),
1625
1737
  maxRuns: z.number().int().positive().optional(),
1626
1738
  expiresIn: z.string().optional(),
1739
+ runNow: z
1740
+ .boolean()
1741
+ .optional()
1742
+ .describe("Also fire once immediately on create. Defaults to true for every, false for cron. Not allowed with at."),
1627
1743
  },
1628
1744
  outputSchema: ScheduleSummarySchema.shape,
1629
- }, async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => {
1745
+ }, async ({ prompt, at, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn, runNow, }) => {
1630
1746
  if (!scheduleService) {
1631
1747
  throw new Error("Schedule service is not configured");
1632
1748
  }
1749
+ const { cadence, oneOff } = resolveScheduleCreateCadence({ at, every, cron, timezone }, new Date());
1750
+ if (oneOff && maxRuns !== undefined && maxRuns !== 1) {
1751
+ throw new Error("maxRuns cannot be combined with at: an at schedule runs exactly once");
1752
+ }
1753
+ if (oneOff && runNow === true) {
1754
+ throw new Error("runNow cannot be combined with at: an at schedule runs only at its time");
1755
+ }
1756
+ const resolvedTarget = await resolveScheduleCreateTarget({ target, provider, cwd });
1633
1757
  const expiresAt = buildScheduleExpiry(expiresIn);
1634
- const schedule = await scheduleService.createOrReplace({
1758
+ const scheduleInput = {
1635
1759
  prompt: prompt.trim(),
1636
- cadence: buildCronScheduleCadence({
1637
- cron,
1638
- ...(timezone !== undefined ? { timezone } : {}),
1639
- }),
1640
- target: resolveNewAgentScheduleTarget({ provider, cwd }),
1760
+ cadence,
1761
+ target: resolvedTarget,
1641
1762
  ...(name?.trim() ? { name: name.trim() } : {}),
1642
- ...(maxRuns === undefined ? {} : { maxRuns }),
1763
+ ...(oneOff
1764
+ ? { maxRuns: 1, runOnCreate: false }
1765
+ : {
1766
+ ...(maxRuns === undefined ? {} : { maxRuns }),
1767
+ ...(runNow === undefined ? {} : { runOnCreate: runNow }),
1768
+ }),
1643
1769
  ...(expiresAt === undefined ? {} : { expiresAt }),
1644
- });
1770
+ };
1771
+ const schedule = await persistNewSchedule(scheduleInput, oneOff);
1645
1772
  return {
1646
1773
  content: [],
1647
1774
  structuredContent: ensureValidJson(toScheduleSummary(schedule)),
@@ -1649,22 +1776,43 @@ export function createPaseoToolCatalog(options) {
1649
1776
  });
1650
1777
  registerTool("create_heartbeat", {
1651
1778
  title: "Create heartbeat",
1652
- description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
1779
+ description: "Schedule a prompt back to yourself: once at a specific time (at), on a repeating interval (every), or on a cron cadence (cron). Equivalent to create_schedule with target self.",
1653
1780
  inputSchema: {
1654
1781
  prompt: z.string().trim().min(1, "prompt is required"),
1655
- cron: z.string().trim().min(1, "cron is required"),
1782
+ at: z
1783
+ .string()
1784
+ .trim()
1785
+ .min(1)
1786
+ .optional()
1787
+ .describe('One-off delivery time. An ISO 8601 instant ("2026-08-21T21:30:00-03:00") or a duration from now ("90m"). Runs exactly once. Mutually exclusive with every and cron.'),
1788
+ every: z
1789
+ .string()
1790
+ .trim()
1791
+ .min(1)
1792
+ .optional()
1793
+ .describe('Repeating interval, for example "5m", "1h", "2h30m". Mutually exclusive with at and cron.'),
1794
+ cron: z
1795
+ .string()
1796
+ .trim()
1797
+ .min(1)
1798
+ .optional()
1799
+ .describe('Repeating cron expression, for example "0 9 * * 1-5". Mutually exclusive with at and every.'),
1656
1800
  timezone: z
1657
1801
  .string()
1658
1802
  .trim()
1659
1803
  .min(1)
1660
1804
  .optional()
1661
- .describe("IANA time zone for the cron cadence. For example: America/New_York."),
1805
+ .describe("IANA time zone for the cron cadence. Requires cron. For example: America/New_York."),
1662
1806
  name: z.string().optional(),
1663
1807
  maxRuns: z.number().int().positive().optional(),
1664
1808
  expiresIn: z.string().optional(),
1809
+ runNow: z
1810
+ .boolean()
1811
+ .optional()
1812
+ .describe("Also fire once immediately on create. Defaults to true for every, false for cron. Not allowed with at."),
1665
1813
  },
1666
1814
  outputSchema: ScheduleSummarySchema.shape,
1667
- }, async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
1815
+ }, async ({ prompt, at, every, cron, timezone, name, maxRuns, expiresIn, runNow }) => {
1668
1816
  if (!scheduleService) {
1669
1817
  throw new Error("Schedule service is not configured");
1670
1818
  }
@@ -1672,23 +1820,50 @@ export function createPaseoToolCatalog(options) {
1672
1820
  throw new Error("create_heartbeat requires an agent-scoped session");
1673
1821
  }
1674
1822
  resolveCallerAgent();
1823
+ const { cadence, oneOff } = resolveScheduleCreateCadence({ at, every, cron, timezone }, new Date());
1824
+ if (oneOff && maxRuns !== undefined && maxRuns !== 1) {
1825
+ throw new Error("maxRuns cannot be combined with at: an at schedule runs exactly once");
1826
+ }
1827
+ if (oneOff && runNow === true) {
1828
+ throw new Error("runNow cannot be combined with at: an at schedule runs only at its time");
1829
+ }
1675
1830
  const expiresAt = buildScheduleExpiry(expiresIn);
1676
- const schedule = await scheduleService.createOrReplace({
1831
+ const scheduleInput = {
1677
1832
  prompt: prompt.trim(),
1678
- cadence: buildCronScheduleCadence({
1679
- cron,
1680
- ...(timezone !== undefined ? { timezone } : {}),
1681
- }),
1833
+ cadence,
1682
1834
  target: { type: "agent", agentId: callerAgentId },
1683
1835
  ...(name?.trim() ? { name: name.trim() } : {}),
1684
- ...(maxRuns === undefined ? {} : { maxRuns }),
1836
+ ...(oneOff
1837
+ ? { maxRuns: 1, runOnCreate: false }
1838
+ : {
1839
+ ...(maxRuns === undefined ? {} : { maxRuns }),
1840
+ ...(runNow === undefined ? {} : { runOnCreate: runNow }),
1841
+ }),
1685
1842
  ...(expiresAt === undefined ? {} : { expiresAt }),
1686
- });
1843
+ };
1844
+ const schedule = await persistNewSchedule(scheduleInput, oneOff);
1687
1845
  return {
1688
1846
  content: [],
1689
1847
  structuredContent: ensureValidJson(toScheduleSummary(schedule)),
1690
1848
  };
1691
1849
  });
1850
+ registerTool("run_schedule_once", {
1851
+ title: "Run schedule once",
1852
+ description: "Fire a schedule immediately, out of band. The cadence and the next scheduled run are left untouched, but the run is recorded and counts toward maxRuns.",
1853
+ inputSchema: {
1854
+ id: z.string(),
1855
+ },
1856
+ outputSchema: StoredScheduleSchema.shape,
1857
+ }, async ({ id }) => {
1858
+ if (!scheduleService) {
1859
+ throw new Error("Schedule service is not configured");
1860
+ }
1861
+ const schedule = await scheduleService.runOnce(id);
1862
+ return {
1863
+ content: [],
1864
+ structuredContent: ensureValidJson(schedule),
1865
+ };
1866
+ });
1692
1867
  registerTool("list_schedules", {
1693
1868
  title: "List schedules",
1694
1869
  description: "List all schedules managed by the daemon.",
@@ -42,6 +42,7 @@
42
42
  * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
43
  * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
44
  * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
45
46
  * - `rename_workspace` mutates.
46
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
47
48
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
@@ -42,6 +42,7 @@
42
42
  * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
43
  * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
44
  * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
45
46
  * - `rename_workspace` mutates.
46
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
47
48
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
@@ -41,6 +41,7 @@ export function buildConfigOverrides(record) {
41
41
  extra: record.config?.extra ?? undefined,
42
42
  systemPrompt: record.config?.systemPrompt ?? undefined,
43
43
  mcpServers: record.config?.mcpServers ?? undefined,
44
+ allowedTools: record.config?.allowedTools ?? undefined,
44
45
  });
45
46
  }
46
47
  export function buildSessionConfig(record, options) {
@@ -58,6 +59,7 @@ export function buildSessionConfig(record, options) {
58
59
  extra: overrides.extra,
59
60
  systemPrompt: overrides.systemPrompt,
60
61
  mcpServers: overrides.mcpServers,
62
+ allowedTools: overrides.allowedTools,
61
63
  });
62
64
  }
63
65
  export function isStoredAgentProviderAvailable(record, validProviders) {
@@ -19,9 +19,21 @@ export interface ResolveOrCreateWorkspaceIdInput {
19
19
  cwd: string;
20
20
  initialTitle: string | null;
21
21
  }
22
+ /**
23
+ * `createdWorkspace` is true ONLY when this call minted a brand new workspace
24
+ * record. Callers use it to decide whether post-create side effects that are
25
+ * only correct on a fresh workspace may run — notably workspace auto-naming,
26
+ * which would otherwise rename a workspace the user already had. Deriving that
27
+ * from the REQUEST (`!msg.workspaceId`) is wrong now that an omitted id can
28
+ * resolve to an existing workspace.
29
+ */
30
+ export interface ResolveOrCreateWorkspaceIdResult {
31
+ workspaceId: string;
32
+ createdWorkspace: boolean;
33
+ }
22
34
  export interface WorkspaceProvisioningService {
23
35
  findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord>;
24
- resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>;
36
+ resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<ResolveOrCreateWorkspaceIdResult>;
25
37
  createWorkspaceForDirectory(cwd: string, title?: string | null): Promise<PersistedWorkspaceRecord>;
26
38
  findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
27
39
  ensureWorkspaceRecordUnarchived(workspace: PersistedWorkspaceRecord): Promise<PersistedWorkspaceRecord>;
@@ -119,12 +119,36 @@ export function createWorkspaceProvisioningService(deps) {
119
119
  }
120
120
  async function resolveOrCreateWorkspaceIdForCreateAgent(input) {
121
121
  if (input.createdWorktree) {
122
- return input.createdWorktree.workspace.workspaceId;
122
+ return {
123
+ workspaceId: input.createdWorktree.workspace.workspaceId,
124
+ createdWorkspace: false,
125
+ };
123
126
  }
124
127
  if (input.requestedWorkspaceId) {
125
- return input.requestedWorkspaceId;
128
+ return { workspaceId: input.requestedWorkspaceId, createdWorkspace: false };
126
129
  }
127
- return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
130
+ // Look before you mint. This branch used to call createWorkspaceForDirectory
131
+ // unconditionally, so every createAgent that omitted `workspaceId` produced a
132
+ // SECOND workspace for a directory that already had one — no lookup, not even
133
+ // against the identical cwd. On one daemon that turned 19 directories into 114
134
+ // workspace records, 48 of them on a single repo root, and the agent surfaced
135
+ // in the fresh workspace instead of the one on screen.
136
+ //
137
+ // 0.3.38 fixed the palette by making its caller pass the id. That closes one
138
+ // door and leaves the hole: any other caller (MCP create_agent, loops,
139
+ // schedules, whatever is written next) still mints. Fix it where ownership is
140
+ // actually decided.
141
+ const existing = await findExactWorkspaceByDirectory(input.cwd, { refreshGit: false });
142
+ if (existing) {
143
+ const reused = await reclassifyOrUnarchiveWorkspaceForDirectory({
144
+ workspace: existing,
145
+ project: await projectRegistry.get(existing.projectId),
146
+ cwd: await resolveWorkspaceDirectory(input.cwd, { refreshGit: false }),
147
+ });
148
+ return { workspaceId: reused.workspaceId, createdWorkspace: false };
149
+ }
150
+ const created = await createWorkspaceForDirectory(input.cwd, input.initialTitle);
151
+ return { workspaceId: created.workspaceId, createdWorkspace: true };
128
152
  }
129
153
  async function createWorkspaceForDirectory(cwd, title) {
130
154
  const checkout = await workspaceGitService.getCheckout(cwd);
@@ -2693,13 +2693,17 @@ export class Session {
2693
2693
  let createAgentConfig = createdWorktree
2694
2694
  ? { ...config, cwd: createdWorktree.worktree.worktreePath }
2695
2695
  : config;
2696
- const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
2696
+ const { workspaceId, createdWorkspace } = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
2697
2697
  createdWorktree,
2698
2698
  requestedWorkspaceId: msg.workspaceId,
2699
2699
  cwd: createAgentConfig.cwd,
2700
2700
  initialTitle: workspacePromptTitle,
2701
2701
  });
2702
- const createdDirectoryWorkspaceForAgent = !createdWorktree && !msg.workspaceId;
2702
+ // Gate auto-naming on what actually happened, NOT on what was requested.
2703
+ // An omitted workspaceId no longer implies a fresh workspace: provisioning
2704
+ // now reuses the one that already owns this cwd, and auto-naming a reused
2705
+ // workspace would rename one the user already had.
2706
+ const createdDirectoryWorkspaceForAgent = !createdWorktree && createdWorkspace;
2703
2707
  // Resuming a past session: the recorded cwd may no longer exist on this
2704
2708
  // machine (different $HOME, deleted folder). The transcript is read from
2705
2709
  // ~/.claude/projects regardless, so fall back to an existing directory