Reduce daemon churn from terminals and archived agents

Decouple terminal list snapshots from workspace status invalidation, and filter archived stored agents before projection so seeded homes do less work.
This commit is contained in:
Mohamed Boudra
2026-06-15 20:07:33 +07:00
parent 754f6336c0
commit 1c9e78616f
16 changed files with 837 additions and 105 deletions
+4 -1
View File
@@ -17,12 +17,15 @@ TerminalSession
│ └── onChange(snapshot, previous) fires only on resolved-state transitions
│
└── onActivityChange({ activity, previous }) subscribed in TerminalManager
├── emits terminalsChanged workspace bucket + tab indicators
├── emits terminalsChanged terminal list/tab indicators only
└── subscribeTerminalActivity per-transition stream for notification policy
└── subscribeTerminalWorkspaceContributionChanged workspace status rollup only
```
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle → working, working → idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate only the affected workspace.
### Transitions carry their own history
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
@@ -212,6 +212,7 @@ function createTerminalManagerStub(overrides: Partial<TerminalManager> = {}): Te
listDirectories: vi.fn().mockReturnValue([]),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn().mockReturnValue(() => {}),
subscribeTerminalWorkspaceContributionChanged: vi.fn().mockReturnValue(() => {}),
...overrides,
} as unknown as TerminalManager;
}
@@ -1261,7 +1261,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const structuredSnapshot = buildStoredAgentPayload(
record,
providerSnapshotManager.listRegisteredProviderIds(),
new Set(providerSnapshotManager.listRegisteredProviderIds()),
);
return {
content: [],
@@ -1308,7 +1308,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
);
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
const storedRecords = await agentStorage.list();
const registeredProviderIds = providerSnapshotManager.listRegisteredProviderIds();
const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds());
const storedAgents = storedRecords
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
@@ -24,6 +24,19 @@ interface BuildSessionConfigOptions {
validProviders?: Iterable<AgentProvider>;
}
function isProviderRegistered(
validProviders: Iterable<AgentProvider> | undefined,
provider: AgentProvider,
): boolean {
if (!validProviders) {
return true;
}
if (validProviders instanceof Set) {
return validProviders.has(provider);
}
return new Set(validProviders).has(provider);
}
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
@@ -67,9 +80,7 @@ export function buildSessionConfig(
record: StoredAgentRecord,
options?: BuildSessionConfigOptions,
): AgentSessionConfig | null {
const validProviders = options?.validProviders;
const isValidProvider = validProviders ? new Set(validProviders).has(record.provider) : true;
if (!isValidProvider) {
if (!isProviderRegistered(options?.validProviders, record.provider)) {
return null;
}
const overrides = buildConfigOverrides(record);
@@ -90,7 +101,7 @@ export function isStoredAgentProviderAvailable(
record: StoredAgentRecord,
validProviders?: Iterable<AgentProvider>,
): boolean {
return buildSessionConfig(record, { validProviders }) !== null;
return isProviderRegistered(validProviders, record.provider);
}
export function extractTimestamps(record: StoredAgentRecord): {
@@ -117,7 +128,7 @@ export function toAgentPersistenceHandle(
return null;
}
const provider = handle.provider;
if (!new Set(registeredProviders).has(provider)) {
if (!isProviderRegistered(registeredProviders, provider)) {
return null;
}
if (!handle.sessionId) {
+6 -1
View File
@@ -888,10 +888,12 @@ function createWorkspaceGitSnapshot(
function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType<typeof vi.fn> }): {
setTerminalTitle: ReturnType<typeof vi.fn>;
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
subscribeTerminalWorkspaceContributionChanged: ReturnType<typeof vi.fn>;
} {
return {
setTerminalTitle: options?.setTerminalTitle ?? vi.fn(),
subscribeTerminalsChanged: vi.fn(() => () => {}),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}),
};
}
@@ -3548,7 +3550,10 @@ describe("session workspace script handling", () => {
const session = createSessionForTest({
workspaceGitService,
workspaceRegistry,
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
terminalManager: {
subscribeTerminalsChanged: vi.fn(() => () => {}),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}),
},
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
getDaemonTcpPort: () => 6767,
+49 -19
View File
@@ -29,7 +29,10 @@ import {
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type {
TerminalManager,
TerminalWorkspaceContributionChangedEvent,
} from "../terminal/terminal-manager.js";
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import {
@@ -159,6 +162,7 @@ import {
deriveProjectGroupingName,
deriveWorkspaceDisplayName,
generateWorkspaceId,
resolveWorkspaceIdForRecord,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
@@ -820,7 +824,7 @@ export class Session {
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeTerminalActivityEvents: (() => void) | null = null;
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
private clientActivity: {
@@ -1295,16 +1299,15 @@ export class Session {
private subscribeToOptionalManagers(): void {
this.terminalController.start();
if (this.terminalManager) {
this.unsubscribeTerminalActivityEvents = this.terminalManager.subscribeTerminalsChanged(
(event) => {
void this.emitWorkspaceUpdateForCwd(event.cwd).catch((error) => {
this.unsubscribeTerminalWorkspaceContributionEvents =
this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => {
void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: event.cwd },
"Failed to emit workspace update after terminals changed",
{ err: error, terminalId: event.terminalId },
"Failed to emit workspace update after terminal contribution changed",
);
});
},
);
});
}
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
@@ -1484,7 +1487,7 @@ export class Session {
private buildStoredAgentPayload(
record: StoredAgentRecord,
registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(),
registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
): AgentSnapshotPayload {
return buildStoredAgentPayload(record, registeredProviderIds);
}
@@ -6202,8 +6205,12 @@ export class Session {
*/
private async listAgentPayloads(filter?: {
labels?: Record<string, string>;
includeArchived?: boolean;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
const includeArchived = filter?.includeArchived === true;
const labelEntries = filter?.labels ? Object.entries(filter.labels) : [];
// Get live agents with session modes
const agentSnapshots = this.agentManager.listAgents();
const liveAgents = await Promise.all(
@@ -6214,9 +6221,12 @@ export class Session {
// (excluding internal agents which are for ephemeral system tasks)
const registryRecords = await this.agentStorage.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id));
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds());
const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id) && !record.internal)
// Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents.
.filter((record) => includeArchived || !record.archivedAt)
.filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value))
.filter(
(record) =>
filter?.includeUnavailablePersisted === true ||
@@ -6227,14 +6237,14 @@ export class Session {
let agents = [...liveAgents, ...persistedAgents];
agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider));
if (!includeArchived) {
agents = agents.filter((agent) => !agent.archivedAt);
}
// Filter by labels if filter provided
if (filter?.labels) {
const filterLabels = filter.labels;
if (labelEntries.length > 0) {
agents = agents.filter((agent) =>
Object.entries(filterLabels).every(
([key, _value]) => agent.labels[key] === filterLabels[key],
),
labelEntries.every(([key, value]) => agent.labels[key] === value),
);
}
@@ -6398,6 +6408,7 @@ export class Session {
let agents = await this.listAgentPayloads({
labels: filter?.labels,
includeArchived: filter?.includeArchived,
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
});
const activePlacementsByCwd =
@@ -7163,6 +7174,25 @@ export class Session {
return emptyProject ? { emptyProject } : null;
}
private async emitWorkspaceUpdateForTerminalContribution(
event: TerminalWorkspaceContributionChangedEvent,
): Promise<void> {
if (event.workspaceId) {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = resolveWorkspaceIdForRecord(
{ workspaceId: event.workspaceId, cwd: event.cwd },
workspaces,
);
if (workspaceId) {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
skipReconcile: true,
});
return;
}
}
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: {
@@ -9171,9 +9201,9 @@ export class Session {
this.unsubscribeAgentEvents();
this.unsubscribeAgentEvents = null;
}
if (this.unsubscribeTerminalActivityEvents) {
this.unsubscribeTerminalActivityEvents();
this.unsubscribeTerminalActivityEvents = null;
if (this.unsubscribeTerminalWorkspaceContributionEvents) {
this.unsubscribeTerminalWorkspaceContributionEvents();
this.unsubscribeTerminalWorkspaceContributionEvents = null;
}
if (this.unsubscribeProviderSnapshotEvents) {
this.unsubscribeProviderSnapshotEvents();
@@ -5,7 +5,10 @@ import path from "node:path";
import { expect, test, vi } from "vitest";
import { z } from "zod";
import { Session } from "./session.js";
import type { SessionOptions } from "./session.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { createTerminalManager } from "../terminal/terminal-manager.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import type {
@@ -48,6 +51,18 @@ import {
const REPO_CWD = path.resolve("/tmp/repo");
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
const terminalManagers: TerminalManager[] = [];
afterEach(async () => {
while (terminalManagers.length > 0) {
const manager = terminalManagers.pop();
if (manager) {
manager.killAll();
}
}
await flushTerminalContributionWork();
});
interface SessionTestAccess {
projectRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -461,7 +476,9 @@ function createSessionForWorkspaceTests(
appVersion?: string | null;
onMessage?: (message: SessionOutboundMessage) => void;
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
terminalManager?: ReturnType<typeof asTerminalManager> | null;
terminalManager?: TerminalManager | null;
projectRegistry?: SessionOptions["projectRegistry"];
workspaceRegistry?: SessionOptions["workspaceRegistry"];
} = {},
): TestSession {
const logger = {
@@ -517,7 +534,7 @@ function createSessionForWorkspaceTests(
: null,
upsert: async () => {},
}),
projectRegistry: {
projectRegistry: options.projectRegistry ?? {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
@@ -526,7 +543,7 @@ function createSessionForWorkspaceTests(
archive: async () => {},
remove: async () => {},
},
workspaceRegistry: {
workspaceRegistry: options.workspaceRegistry ?? {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [
@@ -788,7 +805,13 @@ test("unsupported persisted agents are excluded from active lists but preserved
await expect(session.listAgentPayloads()).resolves.toEqual([]);
await expect(session.listAgentPayloads({ includeUnavailablePersisted: true })).resolves.toEqual([
await expect(session.listAgentPayloads({ includeUnavailablePersisted: true })).resolves.toEqual(
[],
);
await expect(
session.listAgentPayloads({ includeArchived: true, includeUnavailablePersisted: true }),
).resolves.toEqual([
expect.objectContaining({
id: "agent-unsupported",
provider: "gemini",
@@ -5372,3 +5395,391 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
});
function createSessionWithTerminalManager(options: {
workspaces: PersistedWorkspaceRecord[];
projects: PersistedProjectRecord[];
onMessage?: (message: SessionOutboundMessage) => void;
}): { session: TestSession; terminalManager: TerminalManager } {
const terminalManager = createTerminalManager();
terminalManagers.push(terminalManager);
const projectRegistry: SessionOptions["projectRegistry"] = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => options.projects,
get: async (projectId: string) =>
options.projects.find((project) => project.projectId === projectId) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
};
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => options.workspaces,
get: async (workspaceId: string) =>
options.workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
};
const session = createSessionForWorkspaceTests({
onMessage: options.onMessage,
terminalManager,
projectRegistry,
workspaceRegistry,
});
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-workspaces",
filter: undefined,
isBootstrapping: false,
lastEmittedByWorkspaceId: new Map(),
pendingUpdatesByWorkspaceId: new Map(),
};
return { session, terminalManager };
}
async function flushTerminalContributionWork(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitForWorkspaceUpdate(
emitted: SessionOutboundMessage[],
predicate: (message: Extract<SessionOutboundMessage, { type: "workspace_update" }>) => boolean,
description: string,
): Promise<Extract<SessionOutboundMessage, { type: "workspace_update" }>> {
const startedAt = Date.now();
while (Date.now() - startedAt < 1000) {
const match = filterByType(emitted, "workspace_update").find(predicate);
if (match) {
return match;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Timed out waiting for workspace_update: ${description}`);
}
test("title-only terminal change does not build workspace descriptors or emit workspace_update", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-title-"));
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-title",
projectId: "proj-title",
cwd,
kind: "directory",
displayName: "title-workspace",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-title",
rootPath: cwd,
kind: "non_git",
displayName: "title-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { session, terminalManager } = createSessionWithTerminalManager({
workspaces: [workspace],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const buildDescriptorMapSpy = vi.fn(
async () => new Map([[workspace.workspaceId, expect.any(Object)]]),
);
session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy;
const listAgentPayloadsSpy = vi.fn(async () => []);
session.listAgentPayloads = listAgentPayloadsSpy;
const terminal = await terminalManager.createTerminal({
cwd,
workspaceId: workspace.workspaceId,
});
terminalManager.setTerminalTitle(terminal.id, "New title");
await flushTerminalContributionWork();
expect(buildDescriptorMapSpy).not.toHaveBeenCalled();
expect(listAgentPayloadsSpy).not.toHaveBeenCalled();
expect(filterByType(emitted, "workspace_update")).toHaveLength(0);
});
test("terminal activity contribution change updates the correct workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-activity-"));
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-activity",
projectId: "proj-activity",
cwd,
kind: "directory",
displayName: "activity-workspace",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-activity",
rootPath: cwd,
kind: "non_git",
displayName: "activity-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { terminalManager } = createSessionWithTerminalManager({
workspaces: [workspace],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const terminal = await terminalManager.createTerminal({
cwd,
workspaceId: workspace.workspaceId,
});
await terminalManager.setTerminalActivity(terminal.id, "working");
const update = await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspace.workspaceId &&
message.payload.workspace.status === "running",
"terminal activity marks the owning workspace running",
);
expect(update.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspace.workspaceId,
status: "running",
},
});
});
test("same-cwd workspace attribution targets the terminal's workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
const workspaceA = createPersistedWorkspaceRecord({
workspaceId: "ws-same-a",
projectId: "proj-same",
cwd,
kind: "directory",
displayName: "workspace-a",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspaceB = createPersistedWorkspaceRecord({
workspaceId: "ws-same-b",
projectId: "proj-same",
cwd,
kind: "directory",
displayName: "workspace-b",
createdAt: "2026-03-01T12:00:01.000Z",
updatedAt: "2026-03-01T12:00:01.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-same",
rootPath: cwd,
kind: "non_git",
displayName: "same-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { terminalManager } = createSessionWithTerminalManager({
workspaces: [workspaceA, workspaceB],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const terminal = await terminalManager.createTerminal({
cwd,
workspaceId: workspaceB.workspaceId,
});
await terminalManager.setTerminalActivity(terminal.id, "working");
await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceB.workspaceId &&
message.payload.workspace.status === "running",
"same-cwd terminal activity targets the stamped workspace",
);
const updates = filterByType(emitted, "workspace_update");
const upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
expect(targetIds.has(workspaceA.workspaceId)).toBe(false);
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspaceB.workspaceId,
status: "running",
},
});
});
test("nested worktree attribution targets the deepest active workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const rootCwd = mkdtempSync(path.join(tmpdir(), "paseo-session-nested-"));
const worktreeCwd = path.join(rootCwd, "worktree");
const terminalCwd = path.join(worktreeCwd, "subdir");
mkdirSync(terminalCwd, { recursive: true });
const workspaceRoot = createPersistedWorkspaceRecord({
workspaceId: "ws-root",
projectId: "proj-nested",
cwd: rootCwd,
kind: "local_checkout",
displayName: "root",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspaceWorktree = createPersistedWorkspaceRecord({
workspaceId: "ws-worktree",
projectId: "proj-nested",
cwd: worktreeCwd,
kind: "worktree",
displayName: "worktree",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-nested",
rootPath: rootCwd,
kind: "git",
displayName: "nested-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { terminalManager } = createSessionWithTerminalManager({
workspaces: [workspaceRoot, workspaceWorktree],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const terminal = await terminalManager.createTerminal({ cwd: terminalCwd });
await terminalManager.setTerminalActivity(terminal.id, "working");
await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceWorktree.workspaceId &&
message.payload.workspace.status === "running",
"nested terminal activity targets the deepest workspace",
);
const updates = filterByType(emitted, "workspace_update");
const upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
// The terminal has no workspaceId, so it falls back to cwd attribution.
// The deepest active workspace covering `terminalCwd` should win.
expect(targetIds.has(workspaceWorktree.workspaceId)).toBe(true);
expect(targetIds.has(workspaceRoot.workspaceId)).toBe(false);
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspaceWorktree.workspaceId,
status: "running",
},
});
});
test("removing an idle terminal does not update workspace status", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-idle-"));
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-remove-idle",
projectId: "proj-remove-idle",
cwd,
kind: "directory",
displayName: "remove-idle-workspace",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-remove-idle",
rootPath: cwd,
kind: "non_git",
displayName: "remove-idle-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { session, terminalManager } = createSessionWithTerminalManager({
workspaces: [workspace],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const buildDescriptorMapSpy = vi.fn(async () => new Map());
session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy;
const terminal = await terminalManager.createTerminal({
cwd,
workspaceId: workspace.workspaceId,
});
terminalManager.killTerminal(terminal.id);
await flushTerminalContributionWork();
expect(buildDescriptorMapSpy).not.toHaveBeenCalled();
expect(filterByType(emitted, "workspace_update")).toHaveLength(0);
});
test("removing a contributing terminal clears workspace status", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-contrib-"));
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-remove-contrib",
projectId: "proj-remove-contrib",
cwd,
kind: "directory",
displayName: "remove-contrib-workspace",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-remove-contrib",
rootPath: cwd,
kind: "non_git",
displayName: "remove-contrib-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const { terminalManager } = createSessionWithTerminalManager({
workspaces: [workspace],
projects: [project],
onMessage: (message) => emitted.push(message),
});
const terminal = await terminalManager.createTerminal({
cwd,
workspaceId: workspace.workspaceId,
});
await terminalManager.setTerminalActivity(terminal.id, "working");
await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspace.workspaceId &&
message.payload.workspace.status === "running",
"contributing terminal enters running state",
);
emitted.length = 0;
terminalManager.killTerminal(terminal.id);
const update = await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspace.workspaceId &&
message.payload.workspace.status === "done",
"removing a contributing terminal clears workspace status",
);
expect(update.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspace.workspaceId,
status: "done",
},
});
});
@@ -77,7 +77,10 @@ export function asDaemonConfigStore(stub: {
export function asTerminalManager(stub: {
[K in keyof NonNullable<SessionOptions["terminalManager"]>]?: unknown;
}): NonNullable<SessionOptions["terminalManager"]> {
return createStub<NonNullable<SessionOptions["terminalManager"]>>(stub);
return createStub<NonNullable<SessionOptions["terminalManager"]>>({
subscribeTerminalWorkspaceContributionChanged: () => () => {},
...stub,
});
}
export function asGitHubService(stub: {
@@ -235,6 +235,7 @@ function createTerminalManagerStub(options?: {
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => () => {}),
subscribeTerminalActivity: vi.fn(() => () => {}),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}),
} satisfies TerminalManager,
};
}
@@ -1,6 +1,7 @@
import { it, expect, afterEach } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import type { TerminalWorkspaceContributionChangedEvent } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -528,3 +529,81 @@ it("clearTerminalAttention leaves non-attention terminals unchanged", async () =
expect(session.getActivity()?.state).toBe("working");
});
it("emits workspace contribution event when activity bucket changes", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.setTerminalActivity(session.id, "working");
expect(events).toEqual([
{
terminalId: session.id,
cwd,
workspaceId: "ws-test",
},
]);
unsubscribe();
});
it("does not emit workspace contribution event for title-only changes", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd });
manager.setTerminalTitle(session.id, "New title");
expect(events).toEqual([]);
unsubscribe();
});
it("emits workspace contribution event when a contributing terminal is removed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.setTerminalActivity(session.id, "working");
events.length = 0;
manager.killTerminal(session.id);
expect(events).toEqual([
{
terminalId: session.id,
cwd,
workspaceId: "ws-test",
},
]);
unsubscribe();
});
it("does not emit workspace contribution event when an idle terminal is removed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd });
manager.killTerminal(session.id);
expect(events).toEqual([]);
unsubscribe();
});
@@ -10,6 +10,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import { resolve, sep } from "node:path";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
export interface TerminalListItem {
id: string;
@@ -37,6 +38,16 @@ export interface TerminalActivityTransitionEvent {
export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void;
export interface TerminalWorkspaceContributionChangedEvent {
terminalId: string;
cwd: string;
workspaceId?: string;
}
export type TerminalWorkspaceContributionChangedListener = (
event: TerminalWorkspaceContributionChangedEvent,
) => void;
export interface TerminalManager {
getTerminals(cwd: string, options?: { workspaceId?: string }): Promise<TerminalSession[]>;
createTerminal(options: {
@@ -74,6 +85,9 @@ export interface TerminalManager {
killAll(): void;
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
subscribeTerminalActivity(listener: TerminalActivityListener): () => void;
subscribeTerminalWorkspaceContributionChanged(
listener: TerminalWorkspaceContributionChangedListener,
): () => void;
}
export interface TerminalManagerOptions {
@@ -95,6 +109,8 @@ export function createTerminalManager(
const terminalActivityTokenById = new Map<string, string>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const terminalActivityListeners = new Set<TerminalActivityListener>();
const terminalWorkspaceContributionChangedListeners =
new Set<TerminalWorkspaceContributionChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
function removeSessionById(id: string, options: { kill: boolean }): void {
@@ -137,6 +153,16 @@ export function createTerminalManager(
session.kill();
}
const previousActivity = session.getActivity();
const previousBucket = deriveTerminalActivityStatusBucket(previousActivity);
if (previousBucket !== null) {
emitTerminalWorkspaceContributionChanged({
terminalId: session.id,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
});
}
emitTerminalsChanged({ cwd: session.cwd });
}
@@ -168,6 +194,15 @@ export function createTerminalManager(
const unsubscribeActivity = session.onActivityChange((transition) => {
emitTerminalActivityTransition({ session, transition });
emitTerminalsChanged({ cwd: session.cwd });
const previousBucket = deriveTerminalActivityStatusBucket(transition.previous);
const nextBucket = deriveTerminalActivityStatusBucket(transition.activity);
if (previousBucket !== nextBucket) {
emitTerminalWorkspaceContributionChanged({
terminalId: session.id,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
});
}
});
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
@@ -231,6 +266,18 @@ export function createTerminalManager(
}
}
function emitTerminalWorkspaceContributionChanged(
event: TerminalWorkspaceContributionChangedEvent,
): void {
for (const listener of terminalWorkspaceContributionChangedListeners) {
try {
listener(event);
} catch {
// no-op
}
}
}
return {
async getTerminals(
cwd: string,
@@ -431,5 +478,14 @@ export function createTerminalManager(
terminalActivityListeners.delete(listener);
};
},
subscribeTerminalWorkspaceContributionChanged(
listener: TerminalWorkspaceContributionChangedListener,
): () => void {
terminalWorkspaceContributionChangedListeners.add(listener);
return () => {
terminalWorkspaceContributionChangedListeners.delete(listener);
};
},
};
}
@@ -98,6 +98,7 @@ describe("terminal-session-controller restore", () => {
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
@@ -232,6 +233,7 @@ describe("terminal-session-controller wrap-flag gating", () => {
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
} as unknown as TerminalManager;
const controller = new TerminalSessionController({
terminalManager,
@@ -307,6 +309,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
return vi.fn();
}),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
};
const outboundMessages: SessionOutboundMessage[] = [];
@@ -371,6 +374,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
};
const outboundMessages: SessionOutboundMessage[] = [];
const controller = new TerminalSessionController({
@@ -449,6 +453,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
return vi.fn();
}),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
};
const outboundMessages: SessionOutboundMessage[] = [];
@@ -188,14 +188,6 @@ function watchTerminal(session: TerminalSession): void {
]);
}
manager.subscribeTerminalsChanged((event) => {
sendToParent({
type: "terminalsChanged",
cwd: event.cwd,
terminals: event.terminals,
});
});
function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise<void> {
const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message));
createTerminalQueue = nextRequest.catch(() => {});
@@ -265,33 +257,17 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
}
case "killTerminal": {
const session = manager.getTerminal(message.terminalId);
const cwd = session?.cwd;
manager.killTerminal(message.terminalId);
// Removal is owned by session.onExit -> terminalExit; the parent mirror
// clears contribution and emits terminalsChanged from that single path.
clearTerminalSubscriptions(message.terminalId);
if (cwd) {
sendToParent({
type: "terminalRemoved",
terminalId: message.terminalId,
cwd,
});
}
sendToParent({ type: "response", requestId: message.requestId, ok: true });
return;
}
case "killTerminalAndWait": {
const session = manager.getTerminal(message.terminalId);
const cwd = session?.cwd;
await manager.killTerminalAndWait(message.terminalId, message.options);
clearTerminalSubscriptions(message.terminalId);
if (cwd) {
sendToParent({
type: "terminalRemoved",
terminalId: message.terminalId,
cwd,
});
}
sendToParent({ type: "response", requestId: message.requestId, ok: true });
return;
}
@@ -115,11 +115,6 @@ export type TerminalWorkerEvent =
terminal: WorkerTerminalInfo;
state: TerminalState;
}
| {
type: "terminalRemoved";
terminalId: string;
cwd: string;
}
| {
type: "terminalMessage";
terminalId: string;
@@ -142,11 +137,6 @@ export type TerminalWorkerEvent =
exitCode: number | null;
};
}
| {
type: "terminalsChanged";
cwd: string;
terminals: WorkerTerminalInfo[];
}
| {
type: "terminalActivityChange";
terminalId: string;
@@ -5,7 +5,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
import type { TerminalActivityTransitionEvent, TerminalManager } from "./terminal-manager.js";
import type {
TerminalActivityTransitionEvent,
TerminalManager,
TerminalWorkspaceContributionChangedEvent,
} from "./terminal-manager.js";
import {
resolvePaseoCliBinDir,
resolvePaseoCliExecutablePath,
@@ -753,3 +757,148 @@ it("removes worker terminals after killAndWait", async () => {
expect(manager.getTerminal(session.id)).toBeUndefined();
expect(manager.listDirectories()).not.toContain(cwd);
});
it("produces one terminals-changed snapshot per title change", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
activity: null,
},
state: createTerminalState(),
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((t) => t.id) });
});
worker.emitWorkerMessage({
type: "terminalTitleChange",
terminalId: "terminal-a",
title: "Updated",
});
expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toEqual({
cwd: "/workspace",
terminalIds: ["terminal-a"],
});
});
it("produces one terminals-changed snapshot and one contribution event per activity change", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
},
state: createTerminalState(),
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((t) => t.id) });
});
const contributions: TerminalWorkspaceContributionChangedEvent[] = [];
manager.subscribeTerminalWorkspaceContributionChanged((event) => {
contributions.push(event);
});
const workingActivity = { state: "working" as const, changedAt: 1000 };
worker.emitWorkerMessage({
type: "terminalActivityChange",
terminalId: "terminal-a",
activity: workingActivity,
previous: null,
});
expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toEqual({
cwd: "/workspace",
terminalIds: ["terminal-a"],
});
expect(contributions).toEqual([
{
terminalId: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
},
]);
});
it("removes a killed worker terminal from terminalExit without duplicate snapshots", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const workingActivity = { state: "working" as const, changedAt: 1000 };
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: workingActivity,
},
state: createTerminalState(),
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) });
});
const contributions: TerminalWorkspaceContributionChangedEvent[] = [];
manager.subscribeTerminalWorkspaceContributionChanged((event) => {
contributions.push(event);
});
manager.killTerminal("terminal-a");
const request = worker.sentMessages.find(
(message) => message.type === "killTerminal" && message.terminalId === "terminal-a",
);
if (!request) {
throw new Error("killTerminal request not sent");
}
worker.emitWorkerMessage({ type: "response", requestId: request.requestId, ok: true });
worker.emitWorkerMessage({
type: "terminalExit",
terminalId: "terminal-a",
info: {
exitCode: null,
signal: null,
lastOutputLines: [],
},
});
expect(manager.getTerminal("terminal-a")).toBeUndefined();
expect(snapshots).toEqual([{ cwd: "/workspace", terminalIds: [] }]);
expect(contributions).toEqual([
{
terminalId: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
},
]);
});
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
import type {
ClientMessage,
ServerMessage,
@@ -19,6 +20,8 @@ import type {
TerminalActivityTransitionEvent,
TerminalListItem,
TerminalManager,
TerminalWorkspaceContributionChangedEvent,
TerminalWorkspaceContributionChangedListener,
TerminalsChangedEvent,
TerminalsChangedListener,
} from "./terminal-manager.js";
@@ -141,6 +144,8 @@ export function createWorkerTerminalManager(
const terminalActivityTokenById = new Map<string, string>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const terminalActivityListeners = new Set<TerminalActivityListener>();
const terminalWorkspaceContributionChangedListeners =
new Set<TerminalWorkspaceContributionChangedListener>();
let workerExited = false;
let workerShutdownTimer: ReturnType<typeof setTimeout> | null = null;
@@ -154,6 +159,18 @@ export function createWorkerTerminalManager(
}
}
function emitTerminalWorkspaceContributionChanged(
event: TerminalWorkspaceContributionChangedEvent,
): void {
for (const listener of Array.from(terminalWorkspaceContributionChangedListeners)) {
try {
listener(event);
} catch {
// no-op
}
}
}
function emitTerminalActivityTransition(event: TerminalActivityTransitionEvent): void {
for (const listener of Array.from(terminalActivityListeners)) {
try {
@@ -415,7 +432,15 @@ export function createWorkerTerminalManager(
listener(message.info);
}
record.exitListeners.clear();
removeRecord(message.terminalId);
const previousBucket = deriveTerminalActivityStatusBucket(record.activity);
const removedRecord = removeRecord(message.terminalId);
if (previousBucket !== null && removedRecord) {
emitTerminalWorkspaceContributionChanged({
terminalId: removedRecord.info.id,
cwd: removedRecord.info.cwd,
...(removedRecord.info.workspaceId ? { workspaceId: removedRecord.info.workspaceId } : {}),
});
}
emitTerminalsChanged({
cwd: record.info.cwd,
terminals: listTerminalItemsForCwd(record.info.cwd),
@@ -468,6 +493,7 @@ export function createWorkerTerminalManager(
if (!record) {
return;
}
const previousActivity = record.activity;
record.activity = message.activity;
const transition: TerminalActivityTransition = {
activity: message.activity,
@@ -483,46 +509,28 @@ export function createWorkerTerminalManager(
activity: message.activity,
previous: message.previous,
});
const previousBucket = deriveTerminalActivityStatusBucket(previousActivity);
const nextBucket = deriveTerminalActivityStatusBucket(message.activity);
if (previousBucket !== nextBucket) {
emitTerminalWorkspaceContributionChanged({
terminalId: record.info.id,
cwd: record.info.cwd,
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
});
}
emitTerminalsChanged({
cwd: record.info.cwd,
terminals: listTerminalItemsForCwd(record.info.cwd),
});
}
function handleTerminalsChangedEvent(
message: Extract<TerminalWorkerToParentMessage, { type: "terminalsChanged" }>,
): void {
for (const terminal of message.terminals) {
const record = recordsById.get(terminal.id);
if (record) {
record.activity = terminal.activity;
}
}
emitTerminalsChanged({
cwd: message.cwd,
terminals: message.terminals.map((terminal) => ({
id: terminal.id,
name: terminal.name,
cwd: terminal.cwd,
...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}),
...(terminal.title ? { title: terminal.title } : {}),
activity: terminal.activity,
})),
});
}
function handleWorkerEvent(message: TerminalWorkerToParentMessage): void {
switch (message.type) {
case "terminalCreated": {
registerRecord({ info: message.terminal, state: message.state });
return;
}
case "terminalRemoved": {
removeRecord(message.terminalId);
emitTerminalsChanged({
cwd: message.cwd,
terminals: listTerminalItemsForCwd(message.cwd),
cwd: message.terminal.cwd,
terminals: listTerminalItemsForCwd(message.terminal.cwd),
});
return;
}
@@ -547,11 +555,6 @@ export function createWorkerTerminalManager(
return;
}
case "terminalsChanged": {
handleTerminalsChangedEvent(message);
return;
}
case "terminalActivityChange": {
handleTerminalActivityChangeEvent(message);
return;
@@ -827,6 +830,15 @@ export function createWorkerTerminalManager(
terminalActivityListeners.delete(listener);
};
},
subscribeTerminalWorkspaceContributionChanged(
listener: TerminalWorkspaceContributionChangedListener,
): () => void {
terminalWorkspaceContributionChangedListeners.add(listener);
return () => {
terminalWorkspaceContributionChangedListeners.delete(listener);
};
},
};
}