@getpaseo/server 0.1.95 → 0.1.97-beta.1

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 (134) hide show
  1. package/dist/server/{utils/executable.d.ts → executable-resolution/executable-resolution.d.ts} +2 -2
  2. package/dist/server/{utils/executable.js → executable-resolution/executable-resolution.js} +16 -14
  3. package/dist/server/executable-resolution/windows.d.ts +18 -0
  4. package/dist/server/executable-resolution/windows.js +62 -0
  5. package/dist/server/server/agent/agent-loading.js +4 -1
  6. package/dist/server/server/agent/agent-manager.d.ts +10 -2
  7. package/dist/server/server/agent/agent-manager.js +34 -46
  8. package/dist/server/server/agent/agent-projections.js +3 -0
  9. package/dist/server/server/agent/agent-prompt.js +19 -1
  10. package/dist/server/server/agent/agent-response-loop.js +2 -4
  11. package/dist/server/server/agent/agent-storage.d.ts +18 -19
  12. package/dist/server/server/agent/agent-storage.js +6 -23
  13. package/dist/server/server/agent/create-agent/create.d.ts +2 -12
  14. package/dist/server/server/agent/create-agent/create.js +28 -30
  15. package/dist/server/server/agent/create-agent-lifecycle-dispatch.d.ts +4 -2
  16. package/dist/server/server/agent/create-agent-lifecycle-dispatch.js +31 -22
  17. package/dist/server/server/agent/import-sessions.d.ts +1 -10
  18. package/dist/server/server/agent/import-sessions.js +1 -53
  19. package/dist/server/server/agent/lifecycle-command.js +5 -4
  20. package/dist/server/server/agent/mcp-server.d.ts +8 -5
  21. package/dist/server/server/agent/mcp-server.js +41 -14
  22. package/dist/server/server/agent/mcp-shared.d.ts +6 -3
  23. package/dist/server/server/agent/mcp-shared.js +3 -0
  24. package/dist/server/server/agent/provider-launch-config.js +1 -1
  25. package/dist/server/server/agent/providers/acp-agent.d.ts +5 -0
  26. package/dist/server/server/agent/providers/acp-agent.js +31 -26
  27. package/dist/server/server/agent/providers/claude/agent.js +45 -6
  28. package/dist/server/server/agent/providers/codex-app-server-agent.js +1 -1
  29. package/dist/server/server/agent/providers/copilot-acp-agent.js +1 -0
  30. package/dist/server/server/agent/providers/cursor-acp-agent.d.ts +0 -7
  31. package/dist/server/server/agent/providers/cursor-acp-agent.js +0 -78
  32. package/dist/server/server/agent/providers/mock-load-test-agent.d.ts +2 -0
  33. package/dist/server/server/agent/providers/mock-load-test-agent.js +73 -1
  34. package/dist/server/server/agent/providers/opencode/server-manager.js +1 -1
  35. package/dist/server/server/agent/structured-generation-providers.js +45 -1
  36. package/dist/server/server/agent-attention-policy.d.ts +12 -3
  37. package/dist/server/server/agent-attention-policy.js +15 -3
  38. package/dist/server/server/auto-archive-on-merge/archive-if-safe.d.ts +7 -6
  39. package/dist/server/server/auto-archive-on-merge/archive-if-safe.js +21 -16
  40. package/dist/server/server/bootstrap.d.ts +3 -0
  41. package/dist/server/server/bootstrap.js +91 -12
  42. package/dist/server/server/config.js +1 -0
  43. package/dist/server/server/daemon-config-store.js +1 -0
  44. package/dist/server/server/exports.d.ts +1 -1
  45. package/dist/server/server/exports.js +1 -1
  46. package/dist/server/server/loop-service.d.ts +24 -24
  47. package/dist/server/server/migrations/backfill-workspace-id.migration.d.ts +9 -0
  48. package/dist/server/server/migrations/backfill-workspace-id.migration.js +60 -0
  49. package/dist/server/server/paseo-worktree-service.d.ts +9 -0
  50. package/dist/server/server/paseo-worktree-service.js +71 -12
  51. package/dist/server/server/path-utils.d.ts +1 -0
  52. package/dist/server/server/path-utils.js +6 -1
  53. package/dist/server/server/persisted-config.d.ts +7 -0
  54. package/dist/server/server/persisted-config.js +1 -0
  55. package/dist/server/server/persistence-hooks.d.ts +1 -0
  56. package/dist/server/server/persistence-hooks.js +13 -5
  57. package/dist/server/server/resolve-workspace-id-for-path.d.ts +3 -0
  58. package/dist/server/server/resolve-workspace-id-for-path.js +41 -0
  59. package/dist/server/server/script-proxy.d.ts +1 -1
  60. package/dist/server/server/script-proxy.js +1 -1
  61. package/dist/server/server/service-proxy.js +1 -1
  62. package/dist/server/server/session.d.ts +31 -6
  63. package/dist/server/server/session.js +640 -196
  64. package/dist/server/server/websocket-server.d.ts +5 -0
  65. package/dist/server/server/websocket-server.js +137 -3
  66. package/dist/server/server/workspace-archive-service.d.ts +60 -3
  67. package/dist/server/server/workspace-archive-service.js +217 -4
  68. package/dist/server/server/workspace-directory.d.ts +20 -2
  69. package/dist/server/server/workspace-directory.js +148 -70
  70. package/dist/server/server/workspace-git-service.js +21 -21
  71. package/dist/server/server/workspace-reconciliation-service.d.ts +1 -1
  72. package/dist/server/server/workspace-reconciliation-service.js +21 -22
  73. package/dist/server/server/workspace-registry-bootstrap.js +23 -10
  74. package/dist/server/server/workspace-registry-model.d.ts +3 -3
  75. package/dist/server/server/workspace-registry-model.js +9 -10
  76. package/dist/server/server/workspace-registry.d.ts +17 -4
  77. package/dist/server/server/workspace-registry.js +27 -0
  78. package/dist/server/server/worktree/commands.d.ts +7 -5
  79. package/dist/server/server/worktree/commands.js +38 -18
  80. package/dist/server/server/worktree-bootstrap.d.ts +1 -0
  81. package/dist/server/server/worktree-bootstrap.js +4 -1
  82. package/dist/server/server/worktree-branch-name-generator.d.ts +5 -1
  83. package/dist/server/server/worktree-branch-name-generator.js +8 -2
  84. package/dist/server/server/worktree-session.d.ts +4 -5
  85. package/dist/server/server/worktree-session.js +9 -3
  86. package/dist/server/services/github-service.js +1 -1
  87. package/dist/server/terminal/activity/terminal-activity-tracker.d.ts +20 -0
  88. package/dist/server/terminal/activity/terminal-activity-tracker.js +59 -0
  89. package/dist/server/terminal/agent-hooks/agent-hook-installer.d.ts +62 -0
  90. package/dist/server/terminal/agent-hooks/agent-hook-installer.js +117 -0
  91. package/dist/server/terminal/agent-hooks/claude/claude-settings.d.ts +7 -0
  92. package/dist/server/terminal/agent-hooks/claude/claude-settings.js +88 -0
  93. package/dist/server/terminal/agent-hooks/claude/claude.d.ts +4 -0
  94. package/dist/server/terminal/agent-hooks/claude/claude.js +47 -0
  95. package/dist/server/terminal/agent-hooks/codex/codex-settings.d.ts +7 -0
  96. package/dist/server/terminal/agent-hooks/codex/codex-settings.js +99 -0
  97. package/dist/server/terminal/agent-hooks/codex/codex.d.ts +4 -0
  98. package/dist/server/terminal/agent-hooks/codex/codex.js +30 -0
  99. package/dist/server/terminal/agent-hooks/opencode/opencode-plugin.d.ts +4 -0
  100. package/dist/server/terminal/agent-hooks/opencode/opencode-plugin.js +46 -0
  101. package/dist/server/terminal/agent-hooks/opencode/opencode.d.ts +3 -0
  102. package/dist/server/terminal/agent-hooks/opencode/opencode.js +23 -0
  103. package/dist/server/terminal/agent-hooks/provider-registry.d.ts +24 -0
  104. package/dist/server/terminal/agent-hooks/provider-registry.js +36 -0
  105. package/dist/server/terminal/agent-hooks/terminal-agent-hook-setting.d.ts +10 -0
  106. package/dist/server/terminal/agent-hooks/terminal-agent-hook-setting.js +26 -0
  107. package/dist/server/terminal/terminal-manager-factory.d.ts +4 -1
  108. package/dist/server/terminal/terminal-manager-factory.js +2 -2
  109. package/dist/server/terminal/terminal-manager.d.ts +33 -2
  110. package/dist/server/terminal/terminal-manager.js +144 -18
  111. package/dist/server/terminal/terminal-output-coalescer.d.ts +4 -0
  112. package/dist/server/terminal/terminal-output-coalescer.js +18 -0
  113. package/dist/server/terminal/terminal-restore.d.ts +1 -0
  114. package/dist/server/terminal/terminal-restore.js +6 -0
  115. package/dist/server/terminal/terminal-session-controller.d.ts +4 -2
  116. package/dist/server/terminal/terminal-session-controller.js +65 -24
  117. package/dist/server/terminal/terminal-worker-process.js +146 -63
  118. package/dist/server/terminal/terminal-worker-protocol.d.ts +19 -14
  119. package/dist/server/terminal/terminal.d.ts +42 -0
  120. package/dist/server/terminal/terminal.js +235 -16
  121. package/dist/server/terminal/worker-terminal-manager.d.ts +1 -0
  122. package/dist/server/terminal/worker-terminal-manager.js +220 -36
  123. package/dist/server/utils/build-metadata-prompt.d.ts +1 -1
  124. package/dist/server/utils/github-remote.js +1 -1
  125. package/dist/server/utils/tree-kill.d.ts +2 -2
  126. package/dist/src/{utils/executable.js → executable-resolution/executable-resolution.js} +16 -14
  127. package/dist/src/executable-resolution/windows.js +62 -0
  128. package/dist/src/server/agent/provider-launch-config.js +1 -1
  129. package/dist/src/server/persisted-config.js +1 -0
  130. package/package.json +10 -5
  131. package/dist/server/server/agent/agent-metadata-generator.d.ts +0 -36
  132. package/dist/server/server/agent/agent-metadata-generator.js +0 -112
  133. package/dist/server/server/paseo-worktree-archive-service.d.ts +0 -41
  134. package/dist/server/server/paseo-worktree-archive-service.js +0 -144
@@ -2,7 +2,7 @@ import equal from "fast-deep-equal";
2
2
  import { v4 as uuidv4 } from "uuid";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { stat } from "node:fs/promises";
5
- import { basename, resolve, sep } from "path";
5
+ import { resolve, sep } from "path";
6
6
  import { homedir } from "node:os";
7
7
  import { z } from "zod";
8
8
  import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -42,8 +42,9 @@ import { StructuredAgentFallbackError, StructuredAgentResponseError, generateStr
42
42
  import { resolveStructuredGenerationProviders, } from "./agent/structured-generation-providers.js";
43
43
  import { getAgentStreamEventTurnId, } from "./agent/agent-sdk-types.js";
44
44
  import { ImportSessionsRequestError, importProviderSession, listImportableProviderSessions, normalizeImportAgentRequest, } from "./agent/import-sessions.js";
45
- import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId as normalizePersistedWorkspaceId, deriveProjectGroupingName, classifyDirectoryForProjectMembership, deriveWorkspaceDisplayName, } from "./workspace-registry-model.js";
46
- import { createPersistedProjectRecord, createPersistedWorkspaceRecord, resolveProjectDisplayName, } from "./workspace-registry.js";
45
+ import { checkoutLiteFromGitSnapshot, classifyDirectoryForProjectMembership, deriveWorkspaceDisplayName, generateWorkspaceId, } from "./workspace-registry-model.js";
46
+ import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
47
+ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, resolveProjectDisplayName, resolveWorkspaceDisplayName, resolveWorkspaceName, } from "./workspace-registry.js";
47
48
  import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, wrapSpokenInput, } from "./voice-config.js";
48
49
  import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
49
50
  import { listDirectoryEntries, readExplorerFile, readExplorerFileBytes, getDownloadableFileInfo, } from "./file-explorer/service.js";
@@ -51,7 +52,7 @@ import { readPaseoConfigForEdit, writePaseoConfigForEdit, } from "../utils/paseo
51
52
  import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
52
53
  import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
53
54
  import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
54
- import { checkoutResolvedBranch, commitChanges, mergeToBase, mergeFromBase, pullCurrentBranch, pushCurrentBranch, createPullRequest, renameCurrentBranch, } from "../utils/checkout-git.js";
55
+ import { checkoutResolvedBranch, commitChanges, mergeToBase, mergeFromBase, pullCurrentBranch, pushCurrentBranch, createPullRequest, renameCurrentBranch as renameCurrentBranchDefault, } from "../utils/checkout-git.js";
55
56
  import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
56
57
  import { getProjectIcon } from "../utils/project-icon.js";
57
58
  import { expandTilde } from "../utils/path.js";
@@ -63,11 +64,12 @@ import { ChatServiceError, parseMentionAgentIds, } from "./chat/chat-service.js"
63
64
  import { notifyChatMentions, prepareChatMentionFanout } from "./chat/chat-mentions.js";
64
65
  import { execCommand } from "../utils/spawn.js";
65
66
  import { assertPullRequestAutoMergeDisableReady, assertPullRequestAutoMergeEnableReady, createGitHubService, } from "../services/github-service.js";
66
- import { summarizeFetchWorkspacesEntries, WorkspaceDirectory, } from "./workspace-directory.js";
67
+ import { summarizeFetchWorkspacesEntries, workspaceIdsOnCheckout, WorkspaceDirectory, } from "./workspace-directory.js";
67
68
  import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
68
- import { attemptFirstAgentBranchAutoName, createPaseoWorktree, } from "./paseo-worktree-service.js";
69
- import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
69
+ import { attemptFirstAgentBranchAutoName, createLocalCheckoutWorkspace, createPaseoWorktree, } from "./paseo-worktree-service.js";
70
+ import { generateBranchNameFromFirstAgentContext, } from "./worktree-branch-name-generator.js";
70
71
  import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, createPaseoWorktreeWorkflow as createWorktreeWorkflow, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js";
72
+ import { archiveByScope } from "./workspace-archive-service.js";
71
73
  import { toWorktreeWireError } from "./worktree-errors.js";
72
74
  import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
73
75
  const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -136,7 +138,12 @@ function diffChangeTypeFor(file) {
136
138
  return "D";
137
139
  return "M";
138
140
  }
139
- function buildWorkspaceCheckout(workspace, project) {
141
+ function buildWorkspaceCheckout(workspace, project,
142
+ // The persisted `branch` field is the source of truth, but it is null for
143
+ // records created before branch was lifted to its own field (no migrations,
144
+ // per data-model.md) and for any path that didn't backfill it. Fall back to
145
+ // the live git branch so checkout.currentBranch never regresses to null.
146
+ fallbackBranch) {
140
147
  if (project.kind !== "git") {
141
148
  return {
142
149
  cwd: workspace.cwd,
@@ -148,11 +155,12 @@ function buildWorkspaceCheckout(workspace, project) {
148
155
  mainRepoRoot: null,
149
156
  };
150
157
  }
158
+ const currentBranch = workspace.branch ?? fallbackBranch ?? null;
151
159
  if (workspace.kind === "worktree") {
152
160
  return {
153
161
  cwd: workspace.cwd,
154
162
  isGit: true,
155
- currentBranch: workspace.displayName,
163
+ currentBranch,
156
164
  remoteUrl: null,
157
165
  worktreeRoot: workspace.cwd,
158
166
  isPaseoOwnedWorktree: true,
@@ -162,7 +170,7 @@ function buildWorkspaceCheckout(workspace, project) {
162
170
  return {
163
171
  cwd: workspace.cwd,
164
172
  isGit: true,
165
- currentBranch: workspace.displayName,
173
+ currentBranch,
166
174
  remoteUrl: null,
167
175
  worktreeRoot: workspace.cwd,
168
176
  isPaseoOwnedWorktree: false,
@@ -296,6 +304,7 @@ export class Session {
296
304
  this.agentMcpClient = null;
297
305
  this.agentTools = null;
298
306
  this.unsubscribeAgentEvents = null;
307
+ this.unsubscribeTerminalWorkspaceContributionEvents = null;
299
308
  this.agentUpdatesSubscription = null;
300
309
  this.workspaceUpdatesSubscription = null;
301
310
  this.clientActivity = null;
@@ -331,13 +340,14 @@ export class Session {
331
340
  }
332
341
  },
333
342
  });
334
- const { clientId, appVersion, clientCapabilities, onMessage, onBinaryMessage, onLifecycleIntent, logger, downloadTokenStore, pushTokenStore, paseoHome, worktreesRoot, agentManager, agentStorage, projectRegistry, workspaceRegistry, filesystem, chatService, scheduleService, loopService, checkoutDiffManager, github, workspaceGitService, daemonConfigStore, mcpBaseUrl, stt, sttLanguage, tts, terminalManager, providerSnapshotManager, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, onBranchChanged, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, voice, voiceBridge, dictation, serverId, daemonVersion, daemonRuntimeConfig, } = options;
343
+ const { clientId, appVersion, clientCapabilities, onMessage, onBinaryMessage, getTransportBufferedAmount, onLifecycleIntent, logger, downloadTokenStore, pushTokenStore, paseoHome, worktreesRoot, agentManager, agentStorage, projectRegistry, workspaceRegistry, filesystem, chatService, scheduleService, loopService, checkoutDiffManager, github, renameCurrentBranch, generateWorkspaceName, workspaceGitService, daemonConfigStore, mcpBaseUrl, stt, sttLanguage, tts, terminalManager, providerSnapshotManager, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, onBranchChanged, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, voice, voiceBridge, dictation, serverId, daemonVersion, daemonRuntimeConfig, } = options;
335
344
  this.clientId = clientId;
336
345
  this.appVersion = appVersion ?? null;
337
346
  this.clientCapabilities = parseClientCapabilities(clientCapabilities);
338
347
  this.sessionId = uuidv4();
339
348
  this.onMessage = onMessage;
340
349
  this.onBinaryMessage = onBinaryMessage ?? null;
350
+ this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
341
351
  this.onLifecycleIntent = onLifecycleIntent ?? null;
342
352
  this.downloadTokenStore = downloadTokenStore;
343
353
  this.pushTokenStore = pushTokenStore;
@@ -359,6 +369,8 @@ export class Session {
359
369
  this.loopService = loopService;
360
370
  this.checkoutDiffManager = checkoutDiffManager;
361
371
  this.github = github ?? createGitHubService();
372
+ this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
373
+ this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
362
374
  this.workspaceGitService = workspaceGitService;
363
375
  this.daemonConfigStore = daemonConfigStore;
364
376
  this.mcpBaseUrl = mcpBaseUrl ?? null;
@@ -377,6 +389,7 @@ export class Session {
377
389
  .map((workspace) => workspace.cwd);
378
390
  },
379
391
  clientSupportsWrapReflow: () => this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
392
+ getClientBufferedAmount: () => this.getTransportBufferedAmount(),
380
393
  });
381
394
  this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
382
395
  paseoHome: this.paseoHome,
@@ -387,6 +400,8 @@ export class Session {
387
400
  workspaceGitService: this.workspaceGitService,
388
401
  createPaseoWorktreeWorkflow: (input, workflowOptions) => this.createPaseoWorktreeWorkflow(input, workflowOptions),
389
402
  archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
403
+ findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
404
+ listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
390
405
  archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
391
406
  emit: (message) => this.emit(message),
392
407
  emitAgentRemove: (agentId) => {
@@ -400,8 +415,7 @@ export class Session {
400
415
  emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
401
416
  markWorkspaceArchiving: (workspaceIds, archivingAt) => this.markWorkspaceArchiving(workspaceIds, archivingAt),
402
417
  clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
403
- isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
404
- killTerminalsUnderPath: (rootPath) => this.terminalController.killTerminalsUnderPath(rootPath),
418
+ killTerminalsForWorkspace: (workspaceId) => this.terminalController.killTerminalsForWorkspace(workspaceId),
405
419
  logger: this.sessionLogger,
406
420
  });
407
421
  this.providerSnapshotManager = providerSnapshotManager;
@@ -425,6 +439,7 @@ export class Session {
425
439
  projectRegistry: this.projectRegistry,
426
440
  workspaceRegistry: this.workspaceRegistry,
427
441
  listAgentPayloads: () => this.listAgentPayloads(),
442
+ listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
428
443
  isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
429
444
  buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
430
445
  });
@@ -477,9 +492,6 @@ export class Session {
477
492
  async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds) {
478
493
  await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
479
494
  }
480
- async emitWorkspaceUpdatesForExternalCwds(cwds) {
481
- await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
482
- }
483
495
  async warmWorkspaceGitDataForWorkspace(workspace) {
484
496
  await this.syncWorkspaceGitObserverForWorkspace(workspace);
485
497
  await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
@@ -626,6 +638,14 @@ export class Session {
626
638
  */
627
639
  subscribeToOptionalManagers() {
628
640
  this.terminalController.start();
641
+ if (this.terminalManager) {
642
+ this.unsubscribeTerminalWorkspaceContributionEvents =
643
+ this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => {
644
+ void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => {
645
+ this.sessionLogger.warn({ err: error, terminalId: event.terminalId }, "Failed to emit workspace update after terminal contribution changed");
646
+ });
647
+ });
648
+ }
629
649
  const handleProviderSnapshotChange = (entries, cwd) => {
630
650
  // COMPAT(providersSnapshot): keep provider visibility gating for older clients.
631
651
  const visibleEntries = entries.filter((entry) => this.isProviderVisibleToClient(entry.provider));
@@ -763,7 +783,7 @@ export class Session {
763
783
  payload.archivedAt = storedRecord?.archivedAt ?? null;
764
784
  return payload;
765
785
  }
766
- buildStoredAgentPayload(record, registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds()) {
786
+ buildStoredAgentPayload(record, registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds())) {
767
787
  return buildStoredAgentPayload(record, registeredProviderIds);
768
788
  }
769
789
  isProviderVisibleToClient(provider) {
@@ -867,61 +887,37 @@ export class Session {
867
887
  });
868
888
  }
869
889
  }
870
- async findWorkspaceByDirectory(cwd, options) {
871
- const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
872
- const workspaces = await this.workspaceRegistry.list();
873
- const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
874
- return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
875
- }
876
890
  async findExactWorkspaceByDirectory(cwd, options) {
877
891
  const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
878
892
  const workspaces = await this.workspaceRegistry.list();
879
893
  return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
880
894
  }
881
895
  async resolveWorkspaceDirectory(cwd, options) {
882
- const normalizedCwd = normalizePersistedWorkspaceId(cwd);
896
+ const normalizedCwd = resolve(cwd);
883
897
  if (options?.refreshGit === false) {
884
898
  const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd);
885
- return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd);
899
+ return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
886
900
  }
887
901
  const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
888
- return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
902
+ return resolve(checkout.worktreeRoot ?? normalizedCwd);
889
903
  }
890
904
  async buildProjectPlacementForWorkspace(workspace, projectRecord) {
891
905
  const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
892
906
  if (!project) {
893
907
  throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
894
908
  }
895
- const checkout = buildWorkspaceCheckout(workspace, project);
909
+ const liveBranch = this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
910
+ const checkout = buildWorkspaceCheckout(workspace, project, liveBranch);
896
911
  return {
897
912
  projectKey: project.projectId,
898
913
  projectName: resolveProjectDisplayName(project),
899
914
  checkout,
900
915
  };
901
916
  }
902
- async buildProjectPlacementForCwd(cwd, options) {
903
- const workspace = await this.findWorkspaceByDirectory(cwd, {
904
- refreshGit: options?.refreshGit,
905
- });
906
- if (!workspace) {
907
- if (!options?.fallback) {
908
- return null;
909
- }
910
- const normalizedCwd = normalizePersistedWorkspaceId(cwd);
911
- return {
912
- projectKey: normalizedCwd,
913
- projectName: deriveProjectGroupingName(normalizedCwd),
914
- checkout: {
915
- cwd: normalizedCwd,
916
- isGit: false,
917
- currentBranch: null,
918
- remoteUrl: null,
919
- worktreeRoot: null,
920
- isPaseoOwnedWorktree: false,
921
- mainRepoRoot: null,
922
- },
923
- };
924
- }
917
+ async buildProjectPlacementForWorkspaceId(workspaceId) {
918
+ const workspace = await this.workspaceRegistry.get(workspaceId);
919
+ if (!workspace)
920
+ return null;
925
921
  return this.buildProjectPlacementForWorkspace(workspace);
926
922
  }
927
923
  async forwardAgentUpdate(agent) {
@@ -929,33 +925,41 @@ export class Session {
929
925
  const subscription = this.agentUpdatesSubscription;
930
926
  const payload = await this.buildAgentPayload(agent);
931
927
  if (subscription) {
932
- const project = await this.buildProjectPlacementForCwd(payload.cwd, {
933
- refreshGit: false,
934
- fallback: true,
935
- });
928
+ const project = payload.workspaceId
929
+ ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
930
+ : null;
936
931
  if (!project) {
937
- throw new Error(`Workspace not found for agent ${payload.id}`);
938
- }
939
- const matches = this.matchesAgentFilter({
940
- agent: payload,
941
- project,
942
- filter: subscription.filter,
943
- });
944
- if (matches) {
945
932
  this.bufferOrEmitAgentUpdate(subscription, {
946
- kind: "upsert",
947
- agent: payload,
948
- project,
933
+ kind: "remove",
934
+ agentId: payload.id,
949
935
  });
950
936
  }
951
937
  else {
952
- this.bufferOrEmitAgentUpdate(subscription, {
953
- kind: "remove",
954
- agentId: payload.id,
938
+ const matches = this.matchesAgentFilter({
939
+ agent: payload,
940
+ project,
941
+ filter: subscription.filter,
955
942
  });
943
+ if (matches) {
944
+ this.bufferOrEmitAgentUpdate(subscription, {
945
+ kind: "upsert",
946
+ agent: payload,
947
+ project,
948
+ });
949
+ }
950
+ else {
951
+ this.bufferOrEmitAgentUpdate(subscription, {
952
+ kind: "remove",
953
+ agentId: payload.id,
954
+ });
955
+ }
956
956
  }
957
957
  }
958
- await this.emitWorkspaceUpdateForCwd(payload.cwd);
958
+ // A lifecycle change updates exactly the agent's owning workspace, never
959
+ // every workspace sharing its cwd. Ownership is the agent's workspaceId.
960
+ if (payload.workspaceId) {
961
+ await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
962
+ }
959
963
  }
960
964
  catch (error) {
961
965
  this.sessionLogger.error({ err: error }, "Failed to emit agent update");
@@ -1341,8 +1345,12 @@ export class Session {
1341
1345
  return this.handleOpenProjectRequest(msg);
1342
1346
  case "archive_workspace_request":
1343
1347
  return this.handleArchiveWorkspaceRequest(msg);
1348
+ case "workspace.create.request":
1349
+ return this.handleWorkspaceCreateRequest(msg);
1344
1350
  case "workspace.clear_attention.request":
1345
1351
  return this.handleWorkspaceClearAttentionRequest(msg);
1352
+ case "workspace.title.set.request":
1353
+ return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
1346
1354
  case "file_explorer_request":
1347
1355
  return this.handleFileExplorerRequest(msg);
1348
1356
  case "project_icon_request":
@@ -1506,8 +1514,8 @@ export class Session {
1506
1514
  }
1507
1515
  async handleDeleteAgentRequest(agentId, requestId) {
1508
1516
  this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
1509
- const knownCwd = this.agentManager.getAgent(agentId)?.cwd ??
1510
- (await this.agentStorage.get(agentId))?.cwd ??
1517
+ const knownWorkspaceId = this.agentManager.getAgent(agentId)?.workspaceId ??
1518
+ (await this.agentStorage.get(agentId))?.workspaceId ??
1511
1519
  null;
1512
1520
  // File-backed storage still needs an early delete fence before closeAgent().
1513
1521
  beginAgentDeleteIfSupported(this.agentStorage, agentId);
@@ -1540,8 +1548,8 @@ export class Session {
1540
1548
  agentId,
1541
1549
  });
1542
1550
  }
1543
- if (knownCwd) {
1544
- await this.emitWorkspaceUpdateForCwd(knownCwd);
1551
+ if (knownWorkspaceId) {
1552
+ await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
1545
1553
  }
1546
1554
  }
1547
1555
  async handleArchiveAgentRequest(agentId, requestId) {
@@ -1564,7 +1572,9 @@ export class Session {
1564
1572
  }, agentId);
1565
1573
  if (this.agentUpdatesSubscription) {
1566
1574
  const payload = this.buildStoredAgentPayload(archivedRecord);
1567
- const project = await this.buildProjectPlacementForCwd(payload.cwd);
1575
+ const project = payload.workspaceId
1576
+ ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
1577
+ : null;
1568
1578
  if (project) {
1569
1579
  const matches = this.matchesAgentFilter({
1570
1580
  agent: payload,
@@ -1588,7 +1598,9 @@ export class Session {
1588
1598
  agentId,
1589
1599
  });
1590
1600
  }
1591
- await this.emitWorkspaceUpdateForCwd(payload.cwd);
1601
+ if (payload.workspaceId) {
1602
+ await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
1603
+ }
1592
1604
  }
1593
1605
  return { agentId, archivedAt };
1594
1606
  }
@@ -1752,6 +1764,67 @@ export class Session {
1752
1764
  });
1753
1765
  }
1754
1766
  }
1767
+ async handleWorkspaceTitleSetRequest(workspaceId, title, requestId) {
1768
+ this.sessionLogger.info({ workspaceId, requestId, hasTitle: typeof title === "string" }, "session: workspace.title.set.request");
1769
+ try {
1770
+ const existing = await this.workspaceRegistry.get(workspaceId);
1771
+ if (!existing) {
1772
+ this.emit({
1773
+ type: "workspace.title.set.response",
1774
+ payload: {
1775
+ requestId,
1776
+ workspaceId,
1777
+ accepted: false,
1778
+ title: null,
1779
+ error: "Workspace not found",
1780
+ },
1781
+ });
1782
+ return;
1783
+ }
1784
+ const trimmed = title?.trim() ?? "";
1785
+ const nextTitle = trimmed.length === 0 ? null : trimmed;
1786
+ await this.workspaceRegistry.upsert({
1787
+ ...existing,
1788
+ title: nextTitle,
1789
+ updatedAt: new Date().toISOString(),
1790
+ });
1791
+ this.emit({
1792
+ type: "workspace.title.set.response",
1793
+ payload: {
1794
+ requestId,
1795
+ workspaceId,
1796
+ accepted: true,
1797
+ title: nextTitle,
1798
+ error: null,
1799
+ },
1800
+ });
1801
+ await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
1802
+ skipReconcile: true,
1803
+ });
1804
+ }
1805
+ catch (error) {
1806
+ this.sessionLogger.error({ err: error, workspaceId, requestId }, "session: workspace.title.set.request error");
1807
+ this.emit({
1808
+ type: "activity_log",
1809
+ payload: {
1810
+ id: uuidv4(),
1811
+ timestamp: new Date(),
1812
+ type: "error",
1813
+ content: `Failed to set workspace title: ${getErrorMessage(error)}`,
1814
+ },
1815
+ });
1816
+ this.emit({
1817
+ type: "workspace.title.set.response",
1818
+ payload: {
1819
+ requestId,
1820
+ workspaceId,
1821
+ accepted: false,
1822
+ title: null,
1823
+ error: getErrorMessageOr(error, "Failed to set workspace title"),
1824
+ },
1825
+ });
1826
+ }
1827
+ }
1755
1828
  toVoiceFeatureUnavailableContext(state) {
1756
1829
  return {
1757
1830
  reasonCode: state.reasonCode,
@@ -2088,7 +2161,7 @@ export class Session {
2088
2161
  let createdAgentId = null;
2089
2162
  try {
2090
2163
  const trimmedPrompt = initialPrompt?.trim();
2091
- const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
2164
+ const { provisionalTitle } = resolveCreateAgentTitles({
2092
2165
  configTitle: config.title,
2093
2166
  initialPrompt: trimmedPrompt,
2094
2167
  });
@@ -2106,19 +2179,23 @@ export class Session {
2106
2179
  const createAgentConfig = createdWorktree
2107
2180
  ? { ...config, cwd: createdWorktree.worktree.worktreePath }
2108
2181
  : 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;
2109
2188
  const { snapshot, liveSnapshot } = await createAgentCommand({
2110
2189
  agentManager: this.agentManager,
2111
2190
  agentStorage: this.agentStorage,
2112
2191
  logger: this.sessionLogger,
2113
2192
  paseoHome: this.paseoHome,
2114
2193
  worktreesRoot: this.worktreesRoot,
2115
- workspaceGitService: this.workspaceGitService,
2116
2194
  providerSnapshotManager: this.providerSnapshotManager,
2117
- daemonConfig: this.readStructuredGenerationDaemonConfig(),
2118
2195
  }, {
2119
2196
  kind: "session",
2120
2197
  config: createAgentConfig,
2121
- workspaceId: msg.workspaceId,
2198
+ workspaceId,
2122
2199
  worktreeName,
2123
2200
  initialPrompt,
2124
2201
  clientMessageId,
@@ -2129,13 +2206,18 @@ export class Session {
2129
2206
  labels,
2130
2207
  env,
2131
2208
  provisionalTitle,
2132
- explicitTitle,
2133
2209
  firstAgentContext,
2134
2210
  buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
2135
- resolveWorkspace: ({ cwd, workspaceId }) => this.resolveCreateAgentWorkspace(cwd, workspaceId),
2136
2211
  });
2137
2212
  createdAgentId = snapshot.id;
2138
2213
  await this.forwardAgentUpdate(snapshot);
2214
+ if (!createdWorktree && trimmedPrompt) {
2215
+ await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
2216
+ workspaceId,
2217
+ cwd: createAgentConfig.cwd,
2218
+ firstAgentContext,
2219
+ });
2220
+ }
2139
2221
  this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
2140
2222
  autoArchive,
2141
2223
  agentId: snapshot.id,
@@ -2184,16 +2266,6 @@ export class Session {
2184
2266
  });
2185
2267
  }
2186
2268
  }
2187
- async resolveCreateAgentWorkspace(cwd, workspaceId) {
2188
- const resolvedWorkspace = workspaceId
2189
- ? await this.workspaceRegistry.get(workspaceId)
2190
- : ((await this.findWorkspaceByDirectory(cwd)) ??
2191
- (await this.findOrCreateWorkspaceForDirectory(cwd)));
2192
- if (!resolvedWorkspace) {
2193
- throw new Error(`Workspace not found: ${workspaceId}`);
2194
- }
2195
- return { workspaceId: resolvedWorkspace.workspaceId };
2196
- }
2197
2269
  async handleResumeAgentRequest(msg) {
2198
2270
  const { handle, overrides, requestId } = msg;
2199
2271
  if (!handle) {
@@ -2283,17 +2355,20 @@ export class Session {
2283
2355
  const { provider, providerHandleId, requestId } = normalized;
2284
2356
  this.sessionLogger.info({ providerHandleId, provider }, `Importing agent ${providerHandleId} (${provider})`);
2285
2357
  try {
2358
+ if (!normalized.cwd) {
2359
+ throw new Error("Import requires cwd from the selected provider session");
2360
+ }
2361
+ // An imported agent mints its own workspace; ownership is its workspaceId,
2362
+ // never an existing same-cwd workspace resolved by path.
2363
+ const workspace = await this.createWorkspaceForDirectory(normalized.cwd);
2286
2364
  const { snapshot, timelineSize } = await importProviderSession({
2287
2365
  request: normalized,
2366
+ workspaceId: workspace.workspaceId,
2288
2367
  agentManager: this.agentManager,
2289
2368
  agentStorage: this.agentStorage,
2290
- workspaceGitService: this.workspaceGitService,
2291
- providerSnapshotManager: this.providerSnapshotManager,
2292
- daemonConfig: this.readStructuredGenerationDaemonConfig(),
2293
- paseoHome: this.paseoHome,
2294
2369
  logger: this.sessionLogger,
2295
2370
  });
2296
- await this.registerWorkspaceForImportedAgent(snapshot.cwd);
2371
+ await this.registerWorkspaceForImportedAgent(workspace);
2297
2372
  const agentPayload = await this.buildAgentPayload(snapshot);
2298
2373
  this.emit({
2299
2374
  type: "status",
@@ -2472,18 +2547,20 @@ export class Session {
2472
2547
  }, config, gitOptions, legacyWorktreeName, firstAgentContext);
2473
2548
  }
2474
2549
  scheduleAutoNameWorkspaceBranchForFirstAgent(input) {
2475
- setTimeout(() => {
2476
- void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => {
2477
- this.sessionLogger.warn({ err: error, cwd: input.workspace.cwd }, "Failed to auto-name worktree branch");
2478
- });
2479
- }, 0);
2550
+ this.scheduleWorkspaceNaming(() => this.maybeAutoNameWorkspaceBranchForFirstAgent(input), {
2551
+ cwd: input.workspace.cwd,
2552
+ message: "Failed to auto-name worktree branch",
2553
+ });
2480
2554
  }
2481
2555
  async maybeAutoNameWorkspaceBranchForFirstAgent(input) {
2556
+ // Capture the generated title from the generator callback so we can write
2557
+ // title := generated title after the branch rename completes.
2558
+ let generatedTitle = null;
2482
2559
  const result = await attemptFirstAgentBranchAutoName({
2483
2560
  cwd: input.workspace.cwd,
2484
2561
  firstAgentContext: input.firstAgentContext,
2485
2562
  generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
2486
- return generateBranchNameFromFirstAgentContext({
2563
+ return this.generateWorkspaceName({
2487
2564
  agentManager: this.agentManager,
2488
2565
  cwd,
2489
2566
  workspaceGitService: this.workspaceGitService,
@@ -2492,21 +2569,80 @@ export class Session {
2492
2569
  currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
2493
2570
  firstAgentContext,
2494
2571
  logger: this.sessionLogger,
2572
+ }).then((r) => {
2573
+ generatedTitle = r?.title ?? null;
2574
+ return r?.branch ?? null;
2495
2575
  });
2496
2576
  },
2497
2577
  });
2498
- if (!result.renamed || !result.branchName) {
2499
- return input.workspace;
2578
+ if (!result.renamed || !generatedTitle) {
2579
+ return;
2500
2580
  }
2501
- const updatedWorkspace = {
2502
- ...input.workspace,
2503
- displayName: result.branchName,
2504
- updatedAt: new Date().toISOString(),
2505
- };
2506
- await this.workspaceRegistry.upsert(updatedWorkspace);
2581
+ // K4: re-read from the registry before writing so any concurrent upsert
2582
+ // that happened between workspace creation and this async path is not clobbered.
2583
+ // The first-agent rename renamed the git branch too, so persist the new branch
2584
+ // alongside the title — both are this path's own fields.
2585
+ await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, {
2586
+ title: generatedTitle,
2587
+ branch: result.branchName,
2588
+ });
2507
2589
  await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
2508
2590
  await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
2509
- return updatedWorkspace;
2591
+ }
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.
2596
+ async applyGeneratedWorkspaceTitle(workspaceId, input) {
2597
+ const current = await this.workspaceRegistry.get(workspaceId);
2598
+ if (!current) {
2599
+ return;
2600
+ }
2601
+ await this.workspaceRegistry.upsert({
2602
+ ...current,
2603
+ title: current.title || input.title,
2604
+ ...(input.branch ? { branch: input.branch } : {}),
2605
+ updatedAt: new Date().toISOString(),
2606
+ });
2607
+ }
2608
+ // Wraps the injected workspace-name generator for a directory workspace.
2609
+ async generateWorkspaceTitleFromContext(input) {
2610
+ return this.generateWorkspaceName({
2611
+ agentManager: this.agentManager,
2612
+ cwd: input.cwd,
2613
+ workspaceGitService: this.workspaceGitService,
2614
+ providerSnapshotManager: this.providerSnapshotManager,
2615
+ daemonConfig: this.readStructuredGenerationDaemonConfig(),
2616
+ currentSelection: this.getFocusedAgentSelectionForCwd(input.cwd),
2617
+ firstAgentContext: input.firstAgentContext,
2618
+ logger: this.sessionLogger,
2619
+ });
2620
+ }
2621
+ // 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.
2624
+ // TODO(K7): same-dir directory-workspace display disambiguation not yet implemented.
2625
+ async maybeAutoNameDirectoryWorkspaceTitle(input) {
2626
+ const generated = await this.generateWorkspaceTitleFromContext({
2627
+ cwd: input.cwd,
2628
+ firstAgentContext: input.firstAgentContext,
2629
+ });
2630
+ const title = generated?.title ?? null;
2631
+ if (!title) {
2632
+ return;
2633
+ }
2634
+ // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing.
2635
+ // Directory workspaces have no branch — write only the title.
2636
+ await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title });
2637
+ await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
2638
+ }
2639
+ async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input) {
2640
+ const workspaceId = input.workspaceId;
2641
+ this.scheduleWorkspaceNaming(() => this.maybeAutoNameDirectoryWorkspaceTitle({
2642
+ workspaceId,
2643
+ cwd: input.cwd,
2644
+ firstAgentContext: input.firstAgentContext,
2645
+ }), { cwd: input.cwd, message: "Failed to auto-name local workspace title" });
2510
2646
  }
2511
2647
  emitProviderDisabledResponse(kind, provider, requestId, fetchedAt) {
2512
2648
  const payload = {
@@ -2719,6 +2855,7 @@ export class Session {
2719
2855
  relayPublicEndpoint: relay?.publicEndpoint,
2720
2856
  relayUseTls: relay?.useTls,
2721
2857
  relayPublicUseTls: relay?.publicUseTls,
2858
+ appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl,
2722
2859
  includeQr: true,
2723
2860
  logger: this.sessionLogger,
2724
2861
  });
@@ -3211,16 +3348,33 @@ export class Session {
3211
3348
  * Handle client heartbeat for activity tracking
3212
3349
  */
3213
3350
  handleClientHeartbeat(msg) {
3351
+ const focusedTerminalId = msg.focusedTerminalId?.trim() || null;
3214
3352
  const appVisibilityChangedAt = msg.appVisibilityChangedAt
3215
3353
  ? new Date(msg.appVisibilityChangedAt)
3216
3354
  : new Date(msg.lastActivityAt);
3217
3355
  this.clientActivity = {
3218
3356
  deviceType: msg.deviceType,
3219
3357
  focusedAgentId: msg.focusedAgentId,
3358
+ focusedTerminalId,
3220
3359
  lastActivityAt: new Date(msg.lastActivityAt),
3221
3360
  appVisible: msg.appVisible,
3222
3361
  appVisibilityChangedAt,
3223
3362
  };
3363
+ if (msg.appVisible && focusedTerminalId) {
3364
+ void this.clearFocusedTerminalAttention(focusedTerminalId);
3365
+ }
3366
+ }
3367
+ async clearFocusedTerminalAttention(terminalId) {
3368
+ const terminalManager = this.terminalManager;
3369
+ if (!terminalManager) {
3370
+ return;
3371
+ }
3372
+ try {
3373
+ await terminalManager.clearTerminalAttention(terminalId);
3374
+ }
3375
+ catch (error) {
3376
+ this.sessionLogger.warn({ err: error, terminalId }, "Failed to clear terminal attention");
3377
+ }
3224
3378
  }
3225
3379
  /**
3226
3380
  * Handle push token registration
@@ -3542,7 +3696,7 @@ export class Session {
3542
3696
  target.watchers.length = 0;
3543
3697
  }
3544
3698
  async removeWorkspaceGitWatchTarget(cwd) {
3545
- const normalizedCwd = normalizePersistedWorkspaceId(cwd);
3699
+ const normalizedCwd = resolve(cwd);
3546
3700
  const target = this.workspaceGitWatchTargets.get(normalizedCwd);
3547
3701
  if (target) {
3548
3702
  this.closeWorkspaceGitWatchTarget(target);
@@ -3550,7 +3704,7 @@ export class Session {
3550
3704
  }
3551
3705
  }
3552
3706
  removeWorkspaceGitSubscription(cwd) {
3553
- const normalizedCwd = normalizePersistedWorkspaceId(cwd);
3707
+ const normalizedCwd = resolve(cwd);
3554
3708
  const target = this.workspaceGitWatchTargets.get(normalizedCwd);
3555
3709
  if (target) {
3556
3710
  const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
@@ -3571,8 +3725,16 @@ export class Session {
3571
3725
  workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null,
3572
3726
  ]);
3573
3727
  }
3728
+ resolveWorkspaceGitWatchTarget(workspaceId) {
3729
+ for (const target of this.workspaceGitWatchTargets.values()) {
3730
+ if (target.workspaceId === workspaceId) {
3731
+ return target;
3732
+ }
3733
+ }
3734
+ return null;
3735
+ }
3574
3736
  shouldSkipWorkspaceGitWatchUpdate(workspaceId, workspace) {
3575
- const target = this.workspaceGitWatchTargets.get(workspaceId);
3737
+ const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
3576
3738
  if (!target) {
3577
3739
  return false;
3578
3740
  }
@@ -3584,7 +3746,7 @@ export class Session {
3584
3746
  return false;
3585
3747
  }
3586
3748
  rememberWorkspaceGitDescriptorState(workspaceId, workspace) {
3587
- const target = this.workspaceGitWatchTargets.get(workspaceId);
3749
+ const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
3588
3750
  if (!target) {
3589
3751
  return;
3590
3752
  }
@@ -3592,7 +3754,7 @@ export class Session {
3592
3754
  target.lastBranchName = workspace?.name ?? null;
3593
3755
  }
3594
3756
  handleWorkspaceGitBranchSnapshot(cwd, branchName) {
3595
- const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
3757
+ const target = this.workspaceGitWatchTargets.get(resolve(cwd));
3596
3758
  if (!target) {
3597
3759
  return;
3598
3760
  }
@@ -3613,7 +3775,7 @@ export class Session {
3613
3775
  }
3614
3776
  }
3615
3777
  syncWorkspaceGitObserver(cwd, options) {
3616
- const normalizedCwd = normalizePersistedWorkspaceId(cwd);
3778
+ const normalizedCwd = resolve(cwd);
3617
3779
  if (!options.isGit) {
3618
3780
  this.removeWorkspaceGitSubscription(normalizedCwd);
3619
3781
  return;
@@ -3634,7 +3796,9 @@ export class Session {
3634
3796
  this.workspaceGitWatchTargets.set(normalizedCwd, target);
3635
3797
  const subscription = this.workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
3636
3798
  this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
3637
- void this.emitWorkspaceUpdateForCwd(normalizedCwd);
3799
+ void this.emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
3800
+ this.sessionLogger.warn({ err: error, cwd: normalizedCwd }, "Failed to emit workspace update after git branch snapshot");
3801
+ });
3638
3802
  this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
3639
3803
  });
3640
3804
  this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
@@ -3739,10 +3903,14 @@ export class Session {
3739
3903
  return;
3740
3904
  }
3741
3905
  try {
3742
- const result = await renameCurrentBranch(cwd, branch);
3906
+ const result = await this.renameCurrentBranch(cwd, branch);
3743
3907
  await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
3744
3908
  this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
3745
3909
  this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
3910
+ // Branch is a git fact derived per-descriptor from each workspace's own
3911
+ // live git snapshot (id → cwd); the reconciliation pass re-persists the
3912
+ // `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
3913
+ // TODO(K10): PR-binding on branch rename is deferred — see plan K10.
3746
3914
  // Push a workspace_update immediately so the sidebar/header reflect
3747
3915
  // the new branch name without waiting for the background git watcher.
3748
3916
  await this.emitWorkspaceUpdateForCwd(cwd);
@@ -4348,18 +4516,19 @@ export class Session {
4348
4516
  async handlePaseoWorktreeArchiveRequest(msg) {
4349
4517
  return handleWorktreeArchiveRequest({
4350
4518
  paseoHome: this.paseoHome,
4351
- worktreesRoot: this.worktreesRoot,
4519
+ paseoWorktreesBaseRoot: this.worktreesRoot,
4352
4520
  github: this.github,
4353
4521
  workspaceGitService: this.workspaceGitService,
4354
4522
  agentManager: this.agentManager,
4355
4523
  agentStorage: this.agentStorage,
4524
+ findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
4525
+ listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
4356
4526
  archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
4357
4527
  emit: (message) => this.emit(message),
4358
4528
  emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
4359
4529
  markWorkspaceArchiving: (workspaceIds, archivingAt) => this.markWorkspaceArchiving(workspaceIds, archivingAt),
4360
4530
  clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
4361
- isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
4362
- killTerminalsUnderPath: (rootPath) => this.terminalController.killTerminalsUnderPath(rootPath),
4531
+ killTerminalsForWorkspace: (workspaceId) => this.terminalController.killTerminalsForWorkspace(workspaceId),
4363
4532
  sessionLogger: this.sessionLogger,
4364
4533
  }, msg);
4365
4534
  }
@@ -4569,10 +4738,30 @@ export class Session {
4569
4738
  });
4570
4739
  }
4571
4740
  }
4741
+ async listTerminalActivityContributions() {
4742
+ const terminalManager = this.terminalManager;
4743
+ if (!terminalManager) {
4744
+ return [];
4745
+ }
4746
+ const directories = terminalManager.listDirectories();
4747
+ const terminalsByDirectory = await Promise.all(directories.map((cwd) => terminalManager.getTerminals(cwd)));
4748
+ return terminalsByDirectory.flat().map((session) => {
4749
+ const contribution = {
4750
+ cwd: session.cwd,
4751
+ activity: session.getActivity(),
4752
+ };
4753
+ if (session.workspaceId) {
4754
+ contribution.workspaceId = session.workspaceId;
4755
+ }
4756
+ return contribution;
4757
+ });
4758
+ }
4572
4759
  /**
4573
4760
  * Build the current agent list payload (live + persisted), optionally filtered by labels.
4574
4761
  */
4575
4762
  async listAgentPayloads(filter) {
4763
+ const includeArchived = filter?.includeArchived === true;
4764
+ const labelEntries = filter?.labels ? Object.entries(filter.labels) : [];
4576
4765
  // Get live agents with session modes
4577
4766
  const agentSnapshots = this.agentManager.listAgents();
4578
4767
  const liveAgents = await Promise.all(agentSnapshots.map((agent) => this.buildAgentPayload(agent)));
@@ -4580,18 +4769,23 @@ export class Session {
4580
4769
  // (excluding internal agents which are for ephemeral system tasks)
4581
4770
  const registryRecords = await this.agentStorage.list();
4582
4771
  const liveIds = new Set(agentSnapshots.map((a) => a.id));
4583
- const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
4772
+ const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds());
4584
4773
  const persistedAgents = registryRecords
4585
4774
  .filter((record) => !liveIds.has(record.id) && !record.internal)
4775
+ // Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents.
4776
+ .filter((record) => includeArchived || !record.archivedAt)
4777
+ .filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value))
4586
4778
  .filter((record) => filter?.includeUnavailablePersisted === true ||
4587
4779
  isStoredAgentProviderAvailable(record, registeredProviderIds))
4588
4780
  .map((record) => this.buildStoredAgentPayload(record, registeredProviderIds));
4589
4781
  let agents = [...liveAgents, ...persistedAgents];
4590
4782
  agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider));
4783
+ if (!includeArchived) {
4784
+ agents = agents.filter((agent) => !agent.archivedAt);
4785
+ }
4591
4786
  // Filter by labels if filter provided
4592
- if (filter?.labels) {
4593
- const filterLabels = filter.labels;
4594
- agents = agents.filter((agent) => Object.entries(filterLabels).every(([key, _value]) => agent.labels[key] === filterLabels[key]));
4787
+ if (labelEntries.length > 0) {
4788
+ agents = agents.filter((agent) => labelEntries.every(([key, value]) => agent.labels[key] === value));
4595
4789
  }
4596
4790
  return agents;
4597
4791
  }
@@ -4653,7 +4847,7 @@ export class Session {
4653
4847
  const payload = this.buildStoredAgentPayload(record);
4654
4848
  return this.isProviderVisibleToClient(payload.provider) ? payload : null;
4655
4849
  }
4656
- async buildActiveProjectPlacementsByWorkspaceCwd() {
4850
+ async buildActiveProjectPlacementsByWorkspaceId() {
4657
4851
  const [persistedWorkspaces, persistedProjects] = await Promise.all([
4658
4852
  this.workspaceRegistry.list(),
4659
4853
  this.projectRegistry.list(),
@@ -4661,7 +4855,7 @@ export class Session {
4661
4855
  const activeProjects = new Map(persistedProjects
4662
4856
  .filter((project) => !project.archivedAt)
4663
4857
  .map((project) => [project.projectId, project]));
4664
- const placementsByCwd = new Map();
4858
+ const placementsByWorkspaceId = new Map();
4665
4859
  const pairs = persistedWorkspaces.flatMap((workspace) => {
4666
4860
  if (workspace.archivedAt)
4667
4861
  return [];
@@ -4672,9 +4866,9 @@ export class Session {
4672
4866
  });
4673
4867
  const placements = await Promise.all(pairs.map(({ workspace, project }) => this.buildProjectPlacementForWorkspace(workspace, project)));
4674
4868
  for (let i = 0; i < pairs.length; i += 1) {
4675
- placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]);
4869
+ placementsByWorkspaceId.set(pairs[i].workspace.workspaceId, placements[i]);
4676
4870
  }
4677
- return placementsByCwd;
4871
+ return placementsByWorkspaceId;
4678
4872
  }
4679
4873
  async collectFetchAgentsEntries(params) {
4680
4874
  const { candidates, limit, getPlacement, filter } = params;
@@ -4683,7 +4877,7 @@ export class Session {
4683
4877
  for (let start = 0; start < candidates.length && matchedEntries.length <= limit; start += batchSize) {
4684
4878
  const batch = candidates.slice(start, start + batchSize);
4685
4879
  const batchEntries = await Promise.all(batch.map(async (agent) => {
4686
- const project = await getPlacement(agent.cwd);
4880
+ const project = await getPlacement(agent.workspaceId);
4687
4881
  return project ? { agent, project } : null;
4688
4882
  }));
4689
4883
  for (const entry of batchEntries) {
@@ -4714,23 +4908,29 @@ export class Session {
4714
4908
  const sort = this.agentsPager.normalizeSort(request.sort);
4715
4909
  let agents = await this.listAgentPayloads({
4716
4910
  labels: filter?.labels,
4911
+ includeArchived: filter?.includeArchived,
4717
4912
  includeUnavailablePersisted: request.type === "fetch_agent_history_request",
4718
4913
  });
4719
- const activePlacementsByCwd = scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
4720
- if (activePlacementsByCwd) {
4721
- agents = agents.filter((agent) => !agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)));
4722
- }
4723
- const placementByCwd = new Map();
4724
- const getPlacement = (cwd) => {
4725
- if (activePlacementsByCwd) {
4726
- return Promise.resolve(activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null);
4914
+ const activePlacementsByWorkspaceId = scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceId() : null;
4915
+ if (activePlacementsByWorkspaceId) {
4916
+ agents = agents.filter((agent) => !agent.archivedAt &&
4917
+ agent.workspaceId != null &&
4918
+ activePlacementsByWorkspaceId.has(agent.workspaceId));
4919
+ }
4920
+ const placementByWorkspaceId = new Map();
4921
+ const getPlacement = (workspaceId) => {
4922
+ if (!workspaceId) {
4923
+ return Promise.resolve(null);
4727
4924
  }
4728
- const existing = placementByCwd.get(cwd);
4925
+ if (activePlacementsByWorkspaceId) {
4926
+ return Promise.resolve(activePlacementsByWorkspaceId.get(workspaceId) ?? null);
4927
+ }
4928
+ const existing = placementByWorkspaceId.get(workspaceId);
4729
4929
  if (existing) {
4730
4930
  return existing;
4731
4931
  }
4732
- const placementPromise = this.buildProjectPlacementForCwd(cwd);
4733
- placementByCwd.set(cwd, placementPromise);
4932
+ const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId);
4933
+ placementByWorkspaceId.set(workspaceId, placementPromise);
4734
4934
  return placementPromise;
4735
4935
  };
4736
4936
  let candidates = [...agents];
@@ -4790,7 +4990,8 @@ export class Session {
4790
4990
  workspaceDirectory: workspace.cwd,
4791
4991
  projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
4792
4992
  workspaceKind: workspace.kind,
4793
- name: workspace.displayName,
4993
+ name: resolveWorkspaceDisplayName(workspace),
4994
+ title: workspace.title,
4794
4995
  archivingAt: null,
4795
4996
  status: "done",
4796
4997
  statusEnteredAt: null,
@@ -4847,7 +5048,7 @@ export class Session {
4847
5048
  const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
4848
5049
  return {
4849
5050
  ...base,
4850
- name: displayName,
5051
+ name: resolveWorkspaceName({ title: workspace.title, derivedDisplayName: displayName }),
4851
5052
  diffStat: snapshot.git.diffStat ?? null,
4852
5053
  gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
4853
5054
  githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
@@ -4866,10 +5067,14 @@ export class Session {
4866
5067
  workspaceDirectory: result.workspace.cwd,
4867
5068
  projectKind: "git",
4868
5069
  workspaceKind: result.workspace.kind,
4869
- name: result.worktree.branchName || result.workspace.displayName,
5070
+ name: resolveWorkspaceName({
5071
+ title: result.workspace.title,
5072
+ derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
5073
+ }),
5074
+ title: result.workspace.title,
4870
5075
  archivingAt: null,
4871
5076
  status: "done",
4872
- statusEnteredAt: null,
5077
+ statusEnteredAt: result.workspace.createdAt,
4873
5078
  activityAt: null,
4874
5079
  diffStat: { additions: 0, deletions: 0 },
4875
5080
  scripts: [],
@@ -4900,8 +5105,13 @@ export class Session {
4900
5105
  async buildWorkspaceDescriptorMap(options) {
4901
5106
  return this.workspaceDirectory.buildDescriptorMap(options);
4902
5107
  }
4903
- resolveRegisteredWorkspaceIdForCwd(cwd, workspaces) {
4904
- return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
5108
+ // external path→workspace adapter, not ownership. Used by archive-by-path flows
5109
+ // where the request carries a worktree path (unique to one workspace) rather
5110
+ // than a workspaceId. This is a directory lookup for an archive target, not a
5111
+ // status/ownership attribution.
5112
+ async findWorkspaceIdForCwd(cwd) {
5113
+ const workspaces = await this.workspaceRegistry.list();
5114
+ return resolveWorkspaceIdForPath(cwd, workspaces);
4905
5115
  }
4906
5116
  matchesWorkspaceFilter(input) {
4907
5117
  return this.workspaceDirectory.matchesFilter(input);
@@ -4969,7 +5179,7 @@ export class Session {
4969
5179
  }
4970
5180
  }
4971
5181
  async findOrCreateWorkspaceForDirectory(cwd) {
4972
- const inputCwd = normalizePersistedWorkspaceId(cwd);
5182
+ const inputCwd = resolve(cwd);
4973
5183
  const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
4974
5184
  const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
4975
5185
  refreshGit: false,
@@ -4977,22 +5187,26 @@ export class Session {
4977
5187
  if (existingWorkspace) {
4978
5188
  if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
4979
5189
  const timestamp = new Date().toISOString();
4980
- const displayName = basename(inputCwd) || inputCwd;
4981
- const projectRecord = createPersistedProjectRecord({
4982
- projectId: inputCwd,
4983
- rootPath: inputCwd,
4984
- kind: "non_git",
4985
- displayName,
4986
- createdAt: timestamp,
4987
- updatedAt: timestamp,
5190
+ const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
5191
+ isGit: false,
5192
+ currentBranch: null,
5193
+ remoteUrl: null,
5194
+ repoRoot: null,
5195
+ isPaseoOwnedWorktree: false,
5196
+ mainRepoRoot: null,
5197
+ });
5198
+ const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
5199
+ const projectRecord = await this.resolveProjectRecordForPlacement({
5200
+ membership,
5201
+ timestamp,
4988
5202
  });
4989
5203
  await this.projectRegistry.upsert(projectRecord);
4990
5204
  const workspaceRecord = createPersistedWorkspaceRecord({
4991
- workspaceId: inputCwd,
5205
+ workspaceId: generateWorkspaceId(),
4992
5206
  projectId: projectRecord.projectId,
4993
5207
  cwd: inputCwd,
4994
- kind: "directory",
4995
- displayName,
5208
+ kind: membership.workspaceKind,
5209
+ displayName: membership.workspaceDisplayName,
4996
5210
  createdAt: timestamp,
4997
5211
  updatedAt: timestamp,
4998
5212
  });
@@ -5017,7 +5231,7 @@ export class Session {
5017
5231
  });
5018
5232
  await this.projectRegistry.upsert(projectRecord);
5019
5233
  const workspaceRecord = createPersistedWorkspaceRecord({
5020
- workspaceId: membership.workspaceId,
5234
+ workspaceId: generateWorkspaceId(),
5021
5235
  projectId: projectRecord.projectId,
5022
5236
  cwd,
5023
5237
  kind: membership.workspaceKind,
@@ -5039,8 +5253,7 @@ export class Session {
5039
5253
  const projectId = projectRecord.projectId;
5040
5254
  const kind = membership.workspaceKind;
5041
5255
  const displayName = membership.workspaceDisplayName;
5042
- if (input.workspace.workspaceId === membership.workspaceId &&
5043
- input.workspace.projectId === projectId &&
5256
+ if (input.workspace.projectId === projectId &&
5044
5257
  input.workspace.kind === kind &&
5045
5258
  input.workspace.displayName === displayName) {
5046
5259
  return this.ensureWorkspaceRecordUnarchived(input.workspace);
@@ -5048,7 +5261,7 @@ export class Session {
5048
5261
  await this.projectRegistry.upsert(projectRecord);
5049
5262
  const nextWorkspace = {
5050
5263
  ...input.workspace,
5051
- workspaceId: membership.workspaceId,
5264
+ workspaceId: input.workspace.workspaceId,
5052
5265
  projectId,
5053
5266
  cwd: input.cwd,
5054
5267
  kind,
@@ -5122,16 +5335,28 @@ export class Session {
5122
5335
  });
5123
5336
  return result;
5124
5337
  }
5338
+ async listActiveWorkspaceRefs() {
5339
+ const workspaces = await this.workspaceRegistry.list();
5340
+ return workspaces
5341
+ .filter((workspace) => !workspace.archivedAt)
5342
+ .map((workspace) => ({
5343
+ workspaceId: workspace.workspaceId,
5344
+ cwd: workspace.cwd,
5345
+ kind: workspace.kind,
5346
+ }));
5347
+ }
5125
5348
  async archiveWorkspaceRecord(workspaceId, archivedAt) {
5126
5349
  const archiveTimestamp = archivedAt ?? new Date().toISOString();
5127
5350
  const existingWorkspace = await archivePersistedWorkspaceRecord({
5128
5351
  workspaceId,
5129
5352
  archivedAt: archiveTimestamp,
5130
5353
  workspaceRegistry: this.workspaceRegistry,
5131
- projectRegistry: this.projectRegistry,
5132
5354
  });
5133
5355
  if (!existingWorkspace) {
5134
- this.removeWorkspaceGitSubscription(workspaceId);
5356
+ const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
5357
+ if (watchTarget) {
5358
+ this.removeWorkspaceGitSubscription(watchTarget.cwd);
5359
+ }
5135
5360
  return;
5136
5361
  }
5137
5362
  if (!existingWorkspace.archivedAt) {
@@ -5144,9 +5369,18 @@ export class Session {
5144
5369
  archivedAt: archiveTimestamp,
5145
5370
  }, "Workspace archived");
5146
5371
  }
5147
- await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
5148
- this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
5149
- this.removeWorkspaceGitSubscription(workspaceId);
5372
+ await this.teardownArchivedWorkspace({
5373
+ workspaceId: existingWorkspace.workspaceId,
5374
+ cwd: existingWorkspace.cwd,
5375
+ });
5376
+ }
5377
+ // Git watch and subscription state is keyed by directory; the script runtime
5378
+ // store is keyed by the opaque workspace id. Each cleanup uses its own key so an
5379
+ // opaque id is never resolved as a filesystem path.
5380
+ async teardownArchivedWorkspace(input) {
5381
+ await this.removeWorkspaceGitWatchTarget(input.cwd);
5382
+ this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
5383
+ this.removeWorkspaceGitSubscription(input.cwd);
5150
5384
  }
5151
5385
  async reconcileAndEmitWorkspaceUpdates() {
5152
5386
  if (!this.workspaceUpdatesSubscription) {
@@ -5178,9 +5412,10 @@ export class Session {
5178
5412
  await Promise.all(result.changesApplied.map(async (change) => {
5179
5413
  switch (change.kind) {
5180
5414
  case "workspace_archived":
5181
- await this.removeWorkspaceGitWatchTarget(change.directory);
5182
- this.scriptRuntimeStore?.removeForWorkspace(change.directory);
5183
- this.removeWorkspaceGitSubscription(change.workspaceId);
5415
+ await this.teardownArchivedWorkspace({
5416
+ workspaceId: change.workspaceId,
5417
+ cwd: change.directory,
5418
+ });
5184
5419
  changedWorkspaceIds.add(change.workspaceId);
5185
5420
  break;
5186
5421
  case "workspace_updated":
@@ -5223,7 +5458,7 @@ export class Session {
5223
5458
  this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)) {
5224
5459
  continue;
5225
5460
  }
5226
- const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
5461
+ const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
5227
5462
  if (watchTarget && this.onBranchChanged) {
5228
5463
  const newBranchName = nextWorkspace?.name ?? null;
5229
5464
  if (newBranchName !== watchTarget.lastBranchName) {
@@ -5236,6 +5471,7 @@ export class Session {
5236
5471
  this.bufferOrEmitWorkspaceUpdate(subscription, {
5237
5472
  kind: "remove",
5238
5473
  id: workspaceId,
5474
+ ...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
5239
5475
  });
5240
5476
  continue;
5241
5477
  }
@@ -5255,10 +5491,38 @@ export class Session {
5255
5491
  void this.reconcileAndEmitWorkspaceUpdates();
5256
5492
  }
5257
5493
  }
5494
+ // When a workspace is archived its project may become empty. Resolve the
5495
+ // now-empty project parent so the `remove` update can carry it, keeping the
5496
+ // sidebar's empty project row in sync without a full re-hydration.
5497
+ async resolveEmptyProjectForArchivedWorkspace(workspaceId) {
5498
+ const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
5499
+ if (!archivedWorkspace) {
5500
+ return null;
5501
+ }
5502
+ const emptyProject = (await this.workspaceDirectory.listEmptyProjects()).find((project) => project.projectId === archivedWorkspace.projectId);
5503
+ return emptyProject ? { emptyProject } : null;
5504
+ }
5505
+ async emitWorkspaceUpdateForTerminalContribution(event) {
5506
+ // A terminal's activity contributes only to the workspace it carries. A
5507
+ // terminal with no workspaceId attributes to nothing — status is per-id.
5508
+ if (!event.workspaceId) {
5509
+ return;
5510
+ }
5511
+ await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], {
5512
+ skipReconcile: true,
5513
+ });
5514
+ }
5515
+ // A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose
5516
+ // OWN cwd is this folder re-derives its git facts from that folder (id → cwd)
5517
+ // and emits its own per-id descriptor. This is a deliberate same-folder fan,
5518
+ // not a cwd → id ownership lookup: git never resolves which workspace owns a
5519
+ // path. See `workspaceIdsOnCheckout`.
5258
5520
  async emitWorkspaceUpdateForCwd(cwd, options) {
5259
- const workspaces = await this.workspaceRegistry.list();
5260
- const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
5261
- await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
5521
+ const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd);
5522
+ if (workspaceIds.length === 0) {
5523
+ return;
5524
+ }
5525
+ await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
5262
5526
  }
5263
5527
  async handleFetchAgents(request) {
5264
5528
  const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
@@ -5450,16 +5714,162 @@ export class Session {
5450
5714
  }
5451
5715
  return { snapshotByWorkspaceId };
5452
5716
  }
5453
- async registerWorkspaceForImportedAgent(cwd) {
5717
+ async registerWorkspaceForImportedAgent(workspace) {
5454
5718
  try {
5455
- const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
5456
5719
  await this.syncWorkspaceGitObserverForWorkspace(workspace);
5457
5720
  await this.describeWorkspaceRecord(workspace);
5458
- await this.emitWorkspaceUpdateForCwd(workspace.cwd);
5721
+ await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
5459
5722
  }
5460
5723
  catch (error) {
5461
- this.sessionLogger.warn({ err: error, cwd }, "Failed to register workspace for imported agent");
5724
+ this.sessionLogger.warn({ err: error, workspaceId: workspace.workspaceId, cwd: workspace.cwd }, "Failed to register workspace for imported agent");
5725
+ }
5726
+ }
5727
+ async handleWorkspaceCreateRequest(request) {
5728
+ try {
5729
+ if (request.source.kind === "directory") {
5730
+ await this.handleWorkspaceCreateLocal(request);
5731
+ return;
5732
+ }
5733
+ await this.handleWorkspaceCreateWorktree(request);
5734
+ }
5735
+ catch (error) {
5736
+ const message = error instanceof Error ? error.message : "Failed to create workspace";
5737
+ this.sessionLogger.error({ err: error, sourceKind: request.source.kind, requestId: request.requestId }, "Failed to create workspace");
5738
+ this.emit({
5739
+ type: "workspace.create.response",
5740
+ payload: {
5741
+ requestId: request.requestId,
5742
+ workspace: null,
5743
+ setupTerminalId: null,
5744
+ error: message,
5745
+ },
5746
+ });
5747
+ }
5748
+ }
5749
+ async handleWorkspaceCreateLocal(request) {
5750
+ if (request.source.kind !== "directory") {
5751
+ return;
5752
+ }
5753
+ const cwd = expandTilde(request.source.path);
5754
+ const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
5755
+ if (!directoryExists) {
5756
+ this.emit({
5757
+ type: "workspace.create.response",
5758
+ payload: {
5759
+ requestId: request.requestId,
5760
+ workspace: null,
5761
+ setupTerminalId: null,
5762
+ error: `Directory not found: ${cwd}`,
5763
+ errorCode: "directory_not_found",
5764
+ },
5765
+ });
5766
+ return;
5767
+ }
5768
+ const workspace = await createLocalCheckoutWorkspace({ cwd, title: request.title ?? null }, {
5769
+ projectRegistry: this.projectRegistry,
5770
+ workspaceRegistry: this.workspaceRegistry,
5771
+ workspaceGitService: this.workspaceGitService,
5772
+ });
5773
+ await this.syncWorkspaceGitObserverForWorkspace(workspace);
5774
+ const descriptor = await this.describeWorkspaceRecord(workspace);
5775
+ this.emit({
5776
+ type: "workspace.create.response",
5777
+ payload: {
5778
+ requestId: request.requestId,
5779
+ workspace: descriptor,
5780
+ setupTerminalId: null,
5781
+ error: null,
5782
+ },
5783
+ });
5784
+ await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
5785
+ void this.workspaceGitService
5786
+ .getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" })
5787
+ .catch((error) => {
5788
+ this.sessionLogger.warn({ err: error, cwd: workspace.cwd }, "Background snapshot refresh failed after workspace.create");
5789
+ });
5790
+ if (request.firstAgentContext) {
5791
+ const firstAgentContext = request.firstAgentContext;
5792
+ this.scheduleWorkspaceNaming(() => this.maybeAutoNameDirectoryWorkspaceTitle({
5793
+ workspaceId: workspace.workspaceId,
5794
+ cwd: workspace.cwd,
5795
+ firstAgentContext,
5796
+ }), { cwd: workspace.cwd, message: "Failed to auto-name directory workspace title" });
5797
+ }
5798
+ }
5799
+ // Schedules a background workspace-naming write off the request path. The
5800
+ // setTimeout(0) keeps the LLM call off the hot path.
5801
+ scheduleWorkspaceNaming(run, context) {
5802
+ setTimeout(() => {
5803
+ void run().catch((error) => {
5804
+ this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
5805
+ });
5806
+ }, 0);
5807
+ }
5808
+ async handleWorkspaceCreateWorktree(request) {
5809
+ if (request.source.kind !== "worktree") {
5810
+ return;
5811
+ }
5812
+ const source = request.source;
5813
+ if (!source.cwd && !source.projectId) {
5814
+ this.emit({
5815
+ type: "workspace.create.response",
5816
+ payload: {
5817
+ requestId: request.requestId,
5818
+ workspace: null,
5819
+ setupTerminalId: null,
5820
+ error: "cwd or projectId is required for a worktree-backed workspace",
5821
+ errorCode: "source_required",
5822
+ },
5823
+ });
5824
+ return;
5825
+ }
5826
+ const sourceCwd = await this.resolveWorktreeSourceCwd({
5827
+ cwd: source.cwd,
5828
+ projectId: source.projectId,
5829
+ });
5830
+ const result = await this.createPaseoWorktreeWorkflow({
5831
+ cwd: sourceCwd,
5832
+ projectId: source.projectId,
5833
+ worktreeSlug: source.worktreeSlug,
5834
+ action: source.action,
5835
+ refName: source.refName,
5836
+ githubPrNumber: source.githubPrNumber,
5837
+ firstAgentContext: request.firstAgentContext,
5838
+ }, source.baseBranch
5839
+ ? { resolveDefaultBranch: async () => source.baseBranch }
5840
+ : undefined);
5841
+ if (request.title?.trim()) {
5842
+ await this.workspaceRegistry.upsert({
5843
+ ...result.workspace,
5844
+ title: request.title.trim(),
5845
+ updatedAt: new Date().toISOString(),
5846
+ });
5847
+ result.workspace.title = request.title.trim();
5462
5848
  }
5849
+ const descriptor = await this.describeCreatedWorktreeWorkspace(result);
5850
+ this.emit({
5851
+ type: "workspace.create.response",
5852
+ payload: {
5853
+ requestId: request.requestId,
5854
+ workspace: descriptor,
5855
+ setupTerminalId: null,
5856
+ error: null,
5857
+ },
5858
+ });
5859
+ this.emit({
5860
+ type: "workspace_update",
5861
+ payload: { kind: "upsert", workspace: descriptor },
5862
+ });
5863
+ }
5864
+ async resolveWorktreeSourceCwd(input) {
5865
+ if (input.cwd) {
5866
+ return expandTilde(input.cwd);
5867
+ }
5868
+ const project = await this.projectRegistry.get(input.projectId);
5869
+ if (!project || project.archivedAt) {
5870
+ throw new Error(`Project not found: ${input.projectId}`);
5871
+ }
5872
+ return project.rootPath;
5463
5873
  }
5464
5874
  async handleOpenProjectRequest(request) {
5465
5875
  const requestedCwd = request.cwd;
@@ -5491,7 +5901,7 @@ export class Session {
5491
5901
  const project = await this.projectRegistry.get(workspace.projectId);
5492
5902
  await this.syncWorkspaceGitObserverForWorkspace(workspace);
5493
5903
  const descriptor = await this.describeWorkspaceRecord(workspace);
5494
- await this.emitWorkspaceUpdateForCwd(workspace.cwd);
5904
+ await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
5495
5905
  this.sessionLogger.info({
5496
5906
  requestedCwd,
5497
5907
  resolvedCwd: cwd,
@@ -5664,7 +6074,7 @@ export class Session {
5664
6074
  createPaseoWorktree: (workflowInput, serviceOptions) => this.createPaseoWorktree(workflowInput, serviceOptions),
5665
6075
  warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
5666
6076
  autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
5667
- emitWorkspaceUpdateForCwd: (cwd, emitOptions) => this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
6077
+ emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
5668
6078
  cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
5669
6079
  this.workspaceSetupSnapshots.set(workspaceId, snapshot);
5670
6080
  },
@@ -5694,12 +6104,33 @@ export class Session {
5694
6104
  if (!existing) {
5695
6105
  throw new Error(`Workspace not found: ${request.workspaceId}`);
5696
6106
  }
5697
- if (existing.kind === "worktree") {
5698
- throw new Error("Use worktree archive for Paseo worktrees");
5699
- }
5700
- const archivedAt = new Date().toISOString();
5701
- await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
5702
- await this.emitWorkspaceUpdateForCwd(existing.cwd);
6107
+ const gitSnapshot = await this.workspaceGitService
6108
+ .getSnapshot(existing.cwd)
6109
+ .catch(() => null);
6110
+ const repoRoot = gitSnapshot?.git?.repoRoot ?? null;
6111
+ await archiveByScope({
6112
+ paseoHome: this.paseoHome,
6113
+ paseoWorktreesBaseRoot: this.worktreesRoot,
6114
+ github: this.github,
6115
+ workspaceGitService: this.workspaceGitService,
6116
+ agentManager: this.agentManager,
6117
+ agentStorage: this.agentStorage,
6118
+ findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
6119
+ listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
6120
+ archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
6121
+ emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
6122
+ markWorkspaceArchiving: (workspaceIds, archivingAt) => this.markWorkspaceArchiving(workspaceIds, archivingAt),
6123
+ clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
6124
+ killTerminalsForWorkspace: (workspaceId) => this.terminalController.killTerminalsForWorkspace(workspaceId),
6125
+ sessionLogger: this.sessionLogger,
6126
+ }, {
6127
+ scope: { kind: "workspace", workspaceId: existing.workspaceId },
6128
+ repoRoot,
6129
+ paseoWorktreesBaseRoot: this.worktreesRoot,
6130
+ requestId: request.requestId,
6131
+ });
6132
+ const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
6133
+ const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString();
5703
6134
  this.emit({
5704
6135
  type: "archive_workspace_response",
5705
6136
  payload: {
@@ -5760,10 +6191,12 @@ export class Session {
5760
6191
  if (!workspace || workspace.archivedAt) {
5761
6192
  throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
5762
6193
  }
5763
- const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd);
6194
+ // Clearing attention is scoped to the workspace that OWNS the agent, by
6195
+ // workspaceId — never by comparing cwd strings. A sibling workspace
6196
+ // sharing the same directory keeps its own agents' attention.
5764
6197
  const clearableAgentIds = agents
5765
6198
  .filter((agent) => !agent.archivedAt)
5766
- .filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd)
6199
+ .filter((agent) => agent.workspaceId === workspace.workspaceId)
5767
6200
  .filter((agent) => agent.requiresAttention === true)
5768
6201
  .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
5769
6202
  .filter((agent) => agent.attentionReason !== "permission")
@@ -5791,7 +6224,7 @@ export class Session {
5791
6224
  };
5792
6225
  await this.agentStorage.upsert(nextRecord);
5793
6226
  const agent = this.buildStoredAgentPayload(nextRecord);
5794
- const project = await this.buildProjectPlacementForCwd(agent.cwd);
6227
+ const project = await this.buildProjectPlacementForWorkspace(workspace);
5795
6228
  this.emit({
5796
6229
  type: "agent_update",
5797
6230
  payload: {
@@ -5862,7 +6295,9 @@ export class Session {
5862
6295
  });
5863
6296
  return;
5864
6297
  }
5865
- const project = await this.buildProjectPlacementForCwd(agent.cwd);
6298
+ const project = agent.workspaceId
6299
+ ? await this.buildProjectPlacementForWorkspaceId(agent.workspaceId)
6300
+ : null;
5866
6301
  this.emit({
5867
6302
  type: "fetch_agent_response",
5868
6303
  payload: { requestId, agent, project, error: null },
@@ -6651,10 +7086,15 @@ export class Session {
6651
7086
  * Emit a message to the client
6652
7087
  */
6653
7088
  emit(msg) {
6654
- this.sessionLogger.trace({
6655
- messageType: msg.type,
6656
- payloadBytes: JSON.stringify(msg).length,
6657
- }, "agent.session.outbound");
7089
+ // JSON.stringify(msg) is only computed when trace is enabled — it runs for
7090
+ // every outbound message otherwise, and trace is disabled by default.
7091
+ // Optional-chained because test logger stubs don't implement isLevelEnabled.
7092
+ if (this.sessionLogger.isLevelEnabled?.("trace")) {
7093
+ this.sessionLogger.trace({
7094
+ messageType: msg.type,
7095
+ payloadBytes: JSON.stringify(msg).length,
7096
+ }, "agent.session.outbound");
7097
+ }
6658
7098
  if (msg.type === "audio_output" &&
6659
7099
  (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) &&
6660
7100
  msg.payload.groupId &&
@@ -6714,6 +7154,10 @@ export class Session {
6714
7154
  this.unsubscribeAgentEvents();
6715
7155
  this.unsubscribeAgentEvents = null;
6716
7156
  }
7157
+ if (this.unsubscribeTerminalWorkspaceContributionEvents) {
7158
+ this.unsubscribeTerminalWorkspaceContributionEvents();
7159
+ this.unsubscribeTerminalWorkspaceContributionEvents = null;
7160
+ }
6717
7161
  if (this.unsubscribeProviderSnapshotEvents) {
6718
7162
  this.unsubscribeProviderSnapshotEvents();
6719
7163
  this.unsubscribeProviderSnapshotEvents = null;