@getpaseo/server 0.1.96 → 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
@@ -1,9 +1,9 @@
1
- import { homedir } from "node:os";
2
- import { sep } from "node:path";
1
+ import { resolve } from "node:path";
3
2
  import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket";
4
3
  import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
5
4
  import { SortablePager } from "./pagination/sortable-pager.js";
6
- import { normalizeWorkspaceId } from "./workspace-registry-model.js";
5
+ import { resolveProjectDisplayName } from "./workspace-registry.js";
6
+ import { deriveTerminalActivityStatusBucket, } from "@getpaseo/protocol/terminal-activity";
7
7
  const FETCH_WORKSPACES_SORT_KEYS = [
8
8
  "status_priority",
9
9
  "activity_at",
@@ -31,6 +31,19 @@ export function summarizeFetchWorkspacesEntries(entries) {
31
31
  workspaces,
32
32
  };
33
33
  }
34
+ /**
35
+ * Git facts (branch, diff, dirty, PR) belong to a checkout on disk, not to a
36
+ * workspace identity. Every workspace whose own cwd is that checkout re-derives
37
+ * its git facts from the same folder. This returns the ids of those workspaces
38
+ * so a git change can fan out to all of them. This is git-fact display, NOT
39
+ * ownership: do not use it to decide which workspace owns an arbitrary path.
40
+ */
41
+ export function workspaceIdsOnCheckout(workspaces, cwd) {
42
+ const resolvedCwd = resolve(cwd);
43
+ return Array.from(workspaces)
44
+ .filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd)
45
+ .map((workspace) => workspace.workspaceId);
46
+ }
34
47
  export class WorkspaceDirectory {
35
48
  constructor(deps) {
36
49
  this.deps = deps;
@@ -73,10 +86,11 @@ export class WorkspaceDirectory {
73
86
  }
74
87
  }
75
88
  async buildDescriptorMap(options) {
76
- const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
89
+ const [agents, persistedWorkspaces, persistedProjects, terminalContributions] = await Promise.all([
77
90
  this.deps.listAgentPayloads(),
78
91
  this.deps.workspaceRegistry.list(),
79
92
  this.deps.projectRegistry.list(),
93
+ this.deps.listTerminalActivityContributions(),
80
94
  ]);
81
95
  const activeProjects = new Map(persistedProjects
82
96
  .filter((project) => !project.archivedAt)
@@ -85,8 +99,9 @@ export class WorkspaceDirectory {
85
99
  const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId));
86
100
  const descriptorsByWorkspaceId = new Map();
87
101
  const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
88
- const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId]));
102
+ const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId));
89
103
  const includedWorkspaces = activeRecords.filter((workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId));
104
+ const activeRecordsByWorkspaceId = new Map(activeRecords.map((workspace) => [workspace.workspaceId, workspace]));
90
105
  const workspaceDescriptors = await Promise.all(includedWorkspaces.map((workspace) => this.deps.buildWorkspaceDescriptor({
91
106
  workspace,
92
107
  projectRecord: activeProjects.get(workspace.projectId) ?? null,
@@ -100,6 +115,44 @@ export class WorkspaceDirectory {
100
115
  });
101
116
  }
102
117
  const activeAgents = agents.filter((agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider));
118
+ this.applyAgentBucketContributions({
119
+ activeAgents,
120
+ descriptorsByWorkspaceId,
121
+ });
122
+ // Terminal activity contributions: working terminal → running bucket.
123
+ const terminalEntriesByWorkspaceId = this.applyTerminalContributions(terminalContributions, descriptorsByWorkspaceId);
124
+ const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(activeAgents, activeWorkspaceIds);
125
+ // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics
126
+ // on `resolveStatusEnteredAt`).
127
+ const nowIso = new Date().toISOString();
128
+ for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) {
129
+ const contributingAgents = contributingAgentsByWorkspaceId.get(workspaceId) ?? [];
130
+ const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
131
+ const result = this.resolveStatusEnteredAt({
132
+ workspaceId,
133
+ winningBucket: descriptor.status,
134
+ contributingAgents,
135
+ terminalEntries,
136
+ previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null,
137
+ workspaceCreatedAt: activeRecordsByWorkspaceId.get(workspaceId)?.createdAt ?? null,
138
+ nowIso,
139
+ });
140
+ descriptor.statusEnteredAt = result.statusEnteredAt;
141
+ if (result.recordUpdate) {
142
+ this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate);
143
+ }
144
+ else if (result.recordDelete) {
145
+ this.bucketHistoryByWorkspaceId.delete(workspaceId);
146
+ }
147
+ }
148
+ return descriptorsByWorkspaceId;
149
+ }
150
+ // Aggregate each agent's state bucket into its owning workspace descriptor,
151
+ // keeping the highest-priority bucket. A record's owner IS its `workspaceId`;
152
+ // status never fans out to same-cwd siblings. Delegated agents contribute to
153
+ // their delegation root's workspace; their own status is ignored unless running.
154
+ applyAgentBucketContributions(params) {
155
+ const { activeAgents, descriptorsByWorkspaceId } = params;
103
156
  const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent]));
104
157
  for (const agent of activeAgents) {
105
158
  let workspaceAgent = agent;
@@ -123,8 +176,8 @@ export class WorkspaceDirectory {
123
176
  attentionReason: agent.attentionReason ?? null,
124
177
  });
125
178
  }
126
- const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd));
127
- if (workspaceId === undefined) {
179
+ const workspaceId = workspaceAgent.workspaceId;
180
+ if (!workspaceId) {
128
181
  continue;
129
182
  }
130
183
  const existing = descriptorsByWorkspaceId.get(workspaceId);
@@ -135,46 +188,55 @@ export class WorkspaceDirectory {
135
188
  existing.status = bucket;
136
189
  }
137
190
  }
138
- // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics
139
- // on `resolveStatusEnteredAt`).
140
- const nowIso = new Date().toISOString();
141
- for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) {
142
- const contributingAgents = agents.filter((agent) => !agent.archivedAt &&
143
- this.deps.isProviderVisibleToClient(agent.provider) &&
144
- workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId);
145
- const result = this.resolveStatusEnteredAt({
146
- workspaceId,
147
- winningBucket: descriptor.status,
148
- contributingAgents,
149
- previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null,
150
- nowIso,
151
- });
152
- descriptor.statusEnteredAt = result.statusEnteredAt;
153
- if (result.recordUpdate) {
154
- this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate);
191
+ }
192
+ // Apply working terminal contributions to descriptor statuses and build a map
193
+ // of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`.
194
+ // A terminal contributes only to the workspace it carries; same-cwd siblings
195
+ // are untouched.
196
+ applyTerminalContributions(terminalContributions, descriptorsByWorkspaceId) {
197
+ const terminalEntriesByWorkspaceId = new Map();
198
+ for (const { workspaceId, activity } of terminalContributions) {
199
+ if (!activity || !workspaceId) {
200
+ continue;
155
201
  }
156
- else if (result.recordDelete) {
157
- this.bucketHistoryByWorkspaceId.delete(workspaceId);
202
+ const bucket = deriveTerminalActivityStatusBucket(activity);
203
+ if (!bucket)
204
+ continue;
205
+ const existing = descriptorsByWorkspaceId.get(workspaceId);
206
+ if (!existing) {
207
+ continue;
208
+ }
209
+ if (getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)) {
210
+ existing.status = bucket;
158
211
  }
212
+ const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
213
+ entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
214
+ terminalEntriesByWorkspaceId.set(workspaceId, entries);
159
215
  }
160
- return descriptorsByWorkspaceId;
216
+ return terminalEntriesByWorkspaceId;
161
217
  }
162
218
  // Aggregate the workspace-level `statusEnteredAt` from its contributing
163
- // agents. Aggregate semantics:
164
- // - winning bucket = highest-priority across contributing agents;
165
- // - entry time = best-effort timestamp from agents in the winning bucket;
219
+ // agents and terminals. Aggregate semantics:
220
+ // - winning bucket = highest-priority across contributing agents and terminals;
221
+ // - entry time = best-effort timestamp from agents/terminals in the winning bucket;
166
222
  // - priority unmasking: when the winning bucket transitions (e.g. a
167
223
  // higher-priority bucket cleared), the new entry time is "now";
168
224
  // - same-bucket emits reuse the previous entered-at;
169
- // - empty workspaces that never had contributing agents get
170
- // `statusEnteredAt: null`.
225
+ // - empty workspaces that never had contributing agents or terminals use
226
+ // their workspace creation time as their initial `done` entry time.
171
227
  // - when archived agents leave a previously active workspace empty, keep
172
228
  // the previous done timestamp or stamp the transition to done now.
173
229
  resolveStatusEnteredAt(params) {
174
- const { winningBucket, contributingAgents, previous, nowIso } = params;
175
- if (contributingAgents.length === 0) {
230
+ const { winningBucket, contributingAgents, terminalEntries, previous, workspaceCreatedAt, nowIso, } = params;
231
+ if (contributingAgents.length === 0 && terminalEntries.length === 0) {
176
232
  if (!previous) {
177
- return { statusEnteredAt: null };
233
+ if (!workspaceCreatedAt) {
234
+ return { statusEnteredAt: null };
235
+ }
236
+ return {
237
+ statusEnteredAt: workspaceCreatedAt,
238
+ recordUpdate: { bucket: "done", enteredAt: workspaceCreatedAt },
239
+ };
178
240
  }
179
241
  const enteredAt = previous.bucket === "done" ? previous.enteredAt : nowIso;
180
242
  return {
@@ -183,7 +245,7 @@ export class WorkspaceDirectory {
183
245
  };
184
246
  }
185
247
  if (!previous) {
186
- const newestInWinningBucket = this.findNewestAgentTimestampInBucket(contributingAgents, winningBucket);
248
+ const newestInWinningBucket = this.findNewestTimestampInBucket(contributingAgents, terminalEntries, winningBucket);
187
249
  const enteredAt = newestInWinningBucket ?? nowIso;
188
250
  return {
189
251
  statusEnteredAt: enteredAt,
@@ -201,13 +263,13 @@ export class WorkspaceDirectory {
201
263
  recordUpdate: previous,
202
264
  };
203
265
  }
204
- // Best-effort newest timestamp across contributing agents whose derived
205
- // bucket matches `winningBucket`. Uses available agent fields:
266
+ // Best-effort newest timestamp across contributing agents and terminal entries
267
+ // whose bucket matches `winningBucket`. For agents, uses:
206
268
  // - `attentionTimestamp` when attention is set (covers attention/failed)
207
269
  // - `updatedAt` as a general fallback for any bucket
208
- // Returns `null` if no matching agent has a parseable timestamp.
209
- findNewestAgentTimestampInBucket(contributingAgents, winningBucket) {
210
- const candidates = contributingAgents
270
+ // Returns `null` if no matching contributor has a parseable timestamp.
271
+ findNewestTimestampInBucket(contributingAgents, terminalEntries, winningBucket) {
272
+ const agentTimestamps = contributingAgents
211
273
  .filter((agent) => {
212
274
  const derived = deriveAgentStateBucket({
213
275
  status: agent.status,
@@ -226,32 +288,33 @@ export class WorkspaceDirectory {
226
288
  // Fall back to updatedAt as a general proxy for recent activity.
227
289
  return agent.updatedAt;
228
290
  })
229
- .filter((value) => typeof value === "string" && value.length > 0)
230
- .sort();
291
+ .filter((value) => typeof value === "string" && value.length > 0);
292
+ const terminalTimestamps = terminalEntries
293
+ .filter((entry) => entry.bucket === winningBucket)
294
+ .map((entry) => entry.changedAtIso);
295
+ const candidates = [...agentTimestamps, ...terminalTimestamps].sort();
231
296
  return candidates.at(-1) ?? null;
232
297
  }
233
- resolveRegisteredWorkspaceIdForCwd(cwd, workspaces) {
234
- const normalizedCwd = normalizeWorkspaceId(cwd);
235
- const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
236
- if (exact) {
237
- return exact.workspaceId;
238
- }
239
- const userHome = homedir();
240
- let bestMatch = null;
241
- for (const workspace of workspaces) {
242
- if (workspace.cwd === userHome)
243
- continue;
244
- if (workspace.archivedAt)
245
- continue;
246
- const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
247
- if (!normalizedCwd.startsWith(prefix)) {
248
- continue;
249
- }
250
- if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
251
- bestMatch = workspace;
252
- }
253
- }
254
- return bestMatch?.workspaceId ?? normalizedCwd;
298
+ // Project parents that have no active workspaces. These persist as first-class
299
+ // empty projects so the sidebar can render an empty project row with a
300
+ // "+ New workspace" affordance.
301
+ async listEmptyProjects() {
302
+ const [persistedWorkspaces, persistedProjects] = await Promise.all([
303
+ this.deps.workspaceRegistry.list(),
304
+ this.deps.projectRegistry.list(),
305
+ ]);
306
+ const projectIdsWithActiveWorkspaces = new Set(persistedWorkspaces
307
+ .filter((workspace) => !workspace.archivedAt)
308
+ .map((workspace) => workspace.projectId));
309
+ return persistedProjects
310
+ .filter((project) => !project.archivedAt && !projectIdsWithActiveWorkspaces.has(project.projectId))
311
+ .map((project) => ({
312
+ projectId: project.projectId,
313
+ projectDisplayName: resolveProjectDisplayName(project),
314
+ projectCustomName: project.customName ?? null,
315
+ projectRootPath: project.rootPath,
316
+ projectKind: project.kind,
317
+ }));
255
318
  }
256
319
  async listDescriptors() {
257
320
  return Array.from((await this.buildDescriptorMap({
@@ -268,11 +331,6 @@ export class WorkspaceDirectory {
268
331
  return false;
269
332
  }
270
333
  }
271
- if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
272
- if (!workspace.id.startsWith(filter.idPrefix.trim())) {
273
- return false;
274
- }
275
- }
276
334
  if (filter.query && filter.query.trim().length > 0) {
277
335
  const query = filter.query.trim().toLocaleLowerCase();
278
336
  const haystacks = [workspace.name, workspace.projectId, workspace.id];
@@ -301,6 +359,12 @@ export class WorkspaceDirectory {
301
359
  const nextCursor = hasMore && pagedEntries.length > 0
302
360
  ? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
303
361
  : null;
362
+ // Empty project parents ride only on the first page so the sidebar can render
363
+ // them without them being duplicated across pagination.
364
+ const projectIdFilter = filter?.projectId?.trim();
365
+ const emptyProjects = cursorToken
366
+ ? []
367
+ : (await this.listEmptyProjects()).filter((project) => !projectIdFilter || project.projectId === projectIdFilter);
304
368
  this.deps.logger.debug({
305
369
  requestId: request.requestId,
306
370
  filter: request.filter ?? null,
@@ -314,6 +378,7 @@ export class WorkspaceDirectory {
314
378
  }, "fetch_workspaces_entries_listed");
315
379
  return {
316
380
  entries: pagedEntries,
381
+ emptyProjects,
317
382
  pageInfo: {
318
383
  nextCursor,
319
384
  prevCursor: request.page?.cursor ?? null,
@@ -322,6 +387,19 @@ export class WorkspaceDirectory {
322
387
  };
323
388
  }
324
389
  }
390
+ function groupAgentsByWorkspaceId(agents, activeWorkspaceIds) {
391
+ const byWorkspaceId = new Map();
392
+ for (const agent of agents) {
393
+ const workspaceId = agent.workspaceId;
394
+ if (!workspaceId || !activeWorkspaceIds.has(workspaceId)) {
395
+ continue;
396
+ }
397
+ const entries = byWorkspaceId.get(workspaceId) ?? [];
398
+ entries.push(agent);
399
+ byWorkspaceId.set(workspaceId, entries);
400
+ }
401
+ return byWorkspaceId;
402
+ }
325
403
  function resolveDelegationRootAgent(agent, activeAgentsById) {
326
404
  const seen = new Set([agent.id]);
327
405
  let current = agent;
@@ -1,6 +1,6 @@
1
1
  import { watch } from "node:fs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { LRUCache } from "lru-cache";
5
5
  import pLimit from "p-limit";
6
6
  import { getCheckoutDiff, getCheckoutSnapshotFacts, getCheckoutShortstat, getCheckoutStatus, getPullRequestStatus, hasOriginRemote, listBranchSuggestions, resolveRepositoryDefaultBranch, resolveBranchCheckout, resolveAbsoluteGitDir, } from "../utils/checkout-git.js";
@@ -11,7 +11,7 @@ import { resolveGitHubRemote } from "../utils/github-remote.js";
11
11
  import { listPaseoWorktrees } from "../utils/worktree.js";
12
12
  import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
13
13
  import { buildWorkspaceGitMetadataFromSnapshot, } from "./workspace-git-metadata.js";
14
- import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js";
14
+ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
15
15
  const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1000;
16
16
  const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180000;
17
17
  export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60000;
@@ -75,7 +75,7 @@ export class WorkspaceGitServiceImpl {
75
75
  this.deps = resolveWorkspaceGitServiceDeps(options.deps);
76
76
  }
77
77
  registerWorkspace(params, listener) {
78
- const cwd = normalizeWorkspaceId(params.cwd);
78
+ const cwd = resolve(params.cwd);
79
79
  const target = this.ensureWorkspaceTarget(cwd);
80
80
  target.listeners.add(listener);
81
81
  if (target.listeners.size === 1) {
@@ -100,7 +100,7 @@ export class WorkspaceGitServiceImpl {
100
100
  };
101
101
  }
102
102
  async getSnapshot(cwd, options) {
103
- cwd = normalizeWorkspaceId(cwd);
103
+ cwd = resolve(cwd);
104
104
  const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
105
105
  const target = this.ensureWorkspaceTarget(cwd);
106
106
  if (!request.force && target.latestSnapshot) {
@@ -109,7 +109,7 @@ export class WorkspaceGitServiceImpl {
109
109
  return this.requestWorkspaceSnapshot(target, request);
110
110
  }
111
111
  async getCheckout(cwd) {
112
- const normalizedCwd = normalizeWorkspaceId(cwd);
112
+ const normalizedCwd = resolve(cwd);
113
113
  try {
114
114
  const status = await this.deps.getCheckoutStatus(normalizedCwd, {
115
115
  paseoHome: this.paseoHome,
@@ -147,11 +147,11 @@ export class WorkspaceGitServiceImpl {
147
147
  }
148
148
  }
149
149
  peekSnapshot(cwd) {
150
- cwd = normalizeWorkspaceId(cwd);
150
+ cwd = resolve(cwd);
151
151
  return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
152
152
  }
153
153
  getCheckoutDiff(cwd, options, readOptions) {
154
- const normalizedCwd = normalizeWorkspaceId(cwd);
154
+ const normalizedCwd = resolve(cwd);
155
155
  const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
156
156
  const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
157
157
  return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, {
@@ -182,13 +182,13 @@ export class WorkspaceGitServiceImpl {
182
182
  ]);
183
183
  }
184
184
  validateBranchRef(cwd, ref, options) {
185
- const normalizedCwd = normalizeWorkspaceId(cwd);
185
+ const normalizedCwd = resolve(cwd);
186
186
  const normalizedRef = ref.trim();
187
187
  const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
188
188
  return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef));
189
189
  }
190
190
  hasLocalBranch(cwd, branch, options) {
191
- const normalizedCwd = normalizeWorkspaceId(cwd);
191
+ const normalizedCwd = resolve(cwd);
192
192
  const normalizedBranch = branch.trim();
193
193
  const ref = `refs/heads/${normalizedBranch}`;
194
194
  const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
@@ -202,14 +202,14 @@ export class WorkspaceGitServiceImpl {
202
202
  });
203
203
  }
204
204
  suggestBranchesForCwd(cwd, options, readOptions) {
205
- const normalizedCwd = normalizeWorkspaceId(cwd);
205
+ const normalizedCwd = resolve(cwd);
206
206
  const query = options?.query ?? "";
207
207
  const limit = options?.limit;
208
208
  const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
209
209
  return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => this.deps.listBranchSuggestions(normalizedCwd, options));
210
210
  }
211
211
  listStashes(cwd, options, readOptions) {
212
- const normalizedCwd = normalizeWorkspaceId(cwd);
212
+ const normalizedCwd = resolve(cwd);
213
213
  const paseoOnly = options?.paseoOnly !== false;
214
214
  const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
215
215
  return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
@@ -235,11 +235,11 @@ export class WorkspaceGitServiceImpl {
235
235
  throw new Error("Create worktree requires a git repository");
236
236
  }
237
237
  return snapshot.git.isPaseoOwnedWorktree
238
- ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd))
239
- : (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd));
238
+ ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd))
239
+ : (snapshot.git.repoRoot ?? resolve(cwd));
240
240
  }
241
241
  async resolveDefaultBranch(cwdOrRepoRoot, options) {
242
- const cwd = normalizeWorkspaceId(cwdOrRepoRoot);
242
+ const cwd = resolve(cwdOrRepoRoot);
243
243
  const key = JSON.stringify(["default-branch", cwd]);
244
244
  return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
245
245
  const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
@@ -251,9 +251,9 @@ export class WorkspaceGitServiceImpl {
251
251
  }
252
252
  async getWorkspaceGitMetadata(cwd, options) {
253
253
  const snapshot = await this.getSnapshot(cwd, options);
254
- const directoryName = options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd;
254
+ const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
255
255
  return buildWorkspaceGitMetadataFromSnapshot({
256
- cwd: normalizeWorkspaceId(cwd),
256
+ cwd: resolve(cwd),
257
257
  directoryName,
258
258
  isGit: snapshot.git.isGit,
259
259
  repoRoot: snapshot.git.repoRoot,
@@ -267,7 +267,7 @@ export class WorkspaceGitServiceImpl {
267
267
  return snapshot.git.remoteUrl;
268
268
  }
269
269
  async refresh(cwd, _options) {
270
- cwd = normalizeWorkspaceId(cwd);
270
+ cwd = resolve(cwd);
271
271
  const target = this.ensureWorkspaceTarget(cwd);
272
272
  await this.refreshWorkspaceTarget(target, {
273
273
  force: false,
@@ -278,7 +278,7 @@ export class WorkspaceGitServiceImpl {
278
278
  this.scheduleWorkspaceObservationSetup(target);
279
279
  }
280
280
  async requestWorkingTreeWatch(cwd, onChange) {
281
- cwd = normalizeWorkspaceId(cwd);
281
+ cwd = resolve(cwd);
282
282
  const target = await this.ensureWorkingTreeWatchTarget(cwd);
283
283
  target.listeners.add(onChange);
284
284
  return {
@@ -289,14 +289,14 @@ export class WorkspaceGitServiceImpl {
289
289
  };
290
290
  }
291
291
  scheduleRefreshForCwd(cwd) {
292
- cwd = normalizeWorkspaceId(cwd);
292
+ cwd = resolve(cwd);
293
293
  const target = this.workspaceTargets.get(cwd);
294
294
  if (target) {
295
295
  this.scheduleWorkspaceRefresh(target);
296
296
  }
297
297
  }
298
298
  onWorkspaceStateMayHaveChanged(cwd) {
299
- const normalizedCwd = normalizeWorkspaceId(cwd);
299
+ const normalizedCwd = resolve(cwd);
300
300
  const target = this.workspaceTargets.get(normalizedCwd);
301
301
  if (!target || target.closed) {
302
302
  return;
@@ -649,7 +649,7 @@ export class WorkspaceGitServiceImpl {
649
649
  }
650
650
  scheduleWorkspaceRefresh(targetOrCwd, options) {
651
651
  const target = typeof targetOrCwd === "string"
652
- ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
652
+ ? this.workspaceTargets.get(resolve(targetOrCwd))
653
653
  : targetOrCwd;
654
654
  if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
655
655
  return;
@@ -20,7 +20,7 @@ export type ReconciliationChange = {
20
20
  kind: "workspace_updated";
21
21
  workspaceId: string;
22
22
  directory: string;
23
- fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
23
+ fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
24
24
  };
25
25
  export interface ReconciliationResult {
26
26
  changesApplied: ReconciliationChange[];
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { normalizeWorkspaceId } from "./workspace-registry-model.js";
2
+ import { resolve } from "node:path";
3
3
  const DEFAULT_RECONCILE_INTERVAL_MS = 60000;
4
4
  function deriveWorkspaceKindFromMetadata(metadata) {
5
5
  if (metadata.projectKind !== "git")
@@ -97,27 +97,18 @@ export class WorkspaceReconciliationService {
97
97
  }));
98
98
  // 2. Merge duplicate active project records that point at the same repo root.
99
99
  await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
100
- // 3. Archive orphaned projects (all workspaces archived/removed)
101
- const orphanedProjects = activeProjects.filter((project) => {
102
- const siblings = workspacesByProject.get(project.projectId) ?? [];
103
- return siblings.length === 0;
104
- });
105
- await Promise.all(orphanedProjects.map(async (project) => {
106
- const timestamp = new Date().toISOString();
107
- await this.projectRegistry.archive(project.projectId, timestamp);
108
- changes.push({
109
- kind: "project_archived",
110
- projectId: project.projectId,
111
- directory: project.rootPath,
112
- reason: "no_active_workspaces",
113
- });
114
- }));
115
- // 4. Reconcile git metadata for active projects whose directories still exist
100
+ // 3. Reconcile git metadata for active projects whose directories still exist.
101
+ // A project with zero active workspaces is a first-class empty project it
102
+ // persists until explicitly removed and still reconciles its own metadata.
103
+ // Skip projects archived earlier in this pass (e.g. merged duplicates) so we
104
+ // don't resurrect them by upserting a stale, non-archived copy.
105
+ const archivedProjectIds = new Set(changes
106
+ .filter((change) => change.kind === "project_archived")
107
+ .map((change) => change.projectId));
116
108
  const projectsToReconcile = activeProjects.filter((project) => {
117
109
  if (project.archivedAt)
118
110
  return false;
119
- const siblings = workspacesByProject.get(project.projectId) ?? [];
120
- if (siblings.length === 0)
111
+ if (archivedProjectIds.has(project.projectId))
121
112
  return false;
122
113
  if (!existsSync(project.rootPath))
123
114
  return false;
@@ -143,7 +134,7 @@ export class WorkspaceReconciliationService {
143
134
  if (project.kind !== "git") {
144
135
  continue;
145
136
  }
146
- const rootKey = normalizeWorkspaceId(project.rootPath);
137
+ const rootKey = resolve(project.rootPath);
147
138
  const group = projectsByRoot.get(rootKey) ?? [];
148
139
  group.push(project);
149
140
  projectsByRoot.set(rootKey, group);
@@ -177,6 +168,14 @@ export class WorkspaceReconciliationService {
177
168
  })));
178
169
  for (const project of duplicateProjects) {
179
170
  workspacesByProject.set(project.projectId, []);
171
+ const timestamp = new Date().toISOString();
172
+ await this.projectRegistry.archive(project.projectId, timestamp);
173
+ changes.push({
174
+ kind: "project_archived",
175
+ projectId: project.projectId,
176
+ directory: project.rootPath,
177
+ reason: "merged_duplicate",
178
+ });
180
179
  }
181
180
  }
182
181
  }
@@ -236,8 +235,8 @@ export class WorkspaceReconciliationService {
236
235
  const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
237
236
  const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
238
237
  const workspaceUpdates = {};
239
- if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
240
- workspaceUpdates.displayName = wsGit.workspaceDisplayName;
238
+ if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) {
239
+ workspaceUpdates.branch = wsGit.currentBranch;
241
240
  }
242
241
  if (workspace.kind !== expectedKind) {
243
242
  workspaceUpdates.kind = expectedKind;