diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx
index 92afb4a43f..57a7d80f25 100644
--- a/docs/hooks/tools.mdx
+++ b/docs/hooks/tools.mdx
@@ -520,6 +520,19 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
+
+heartbeat (5)
+
+| Env var | JSON path | Type | Description |
+| ----------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace's heartbeat settings. |
+| `MUX_TOOL_INPUT_CONTEXT_MODE` | `contextMode` | enum | set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode. |
+| `MUX_TOOL_INPUT_ENABLED` | `enabled` | boolean | set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled". |
+| `MUX_TOOL_INPUT_INTERVAL_MS` | `intervalMs` | number | set: heartbeat interval in milliseconds (300000–86400000). Omit to preserve the current interval or use the global default for new settings. |
+| `MUX_TOOL_INPUT_MESSAGE` | `message` | string | set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message. |
+
+
+
memory (11)
diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx
index 8329d06f90..24af034597 100644
--- a/src/browser/contexts/WorkspaceContext.test.tsx
+++ b/src/browser/contexts/WorkspaceContext.test.tsx
@@ -23,18 +23,9 @@ import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout";
-import type { APIClient } from "@/browser/contexts/API";
+import { APIProvider, type APIClient } from "@/browser/contexts/API";
-// Mock API
let currentClientMock: RecursivePartial = {};
-void mock.module("@/browser/contexts/API", () => ({
- useAPI: () => ({
- api: currentClientMock as APIClient,
- status: "connected" as const,
- error: null,
- }),
- APIProvider: ({ children }: { children: React.ReactNode }) => children,
-}));
// Helper to create test workspace metadata with default runtime config
const createWorkspaceMetadata = (
@@ -1490,13 +1481,15 @@ async function setupWithProjectContext() {
}
render(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
// Inject client immediately to handle race conditions where effects run before store update.
diff --git a/src/browser/hooks/useWorkspaceHeartbeat.test.tsx b/src/browser/hooks/useWorkspaceHeartbeat.test.tsx
index 87baa5e88f..c8792dc0a4 100644
--- a/src/browser/hooks/useWorkspaceHeartbeat.test.tsx
+++ b/src/browser/hooks/useWorkspaceHeartbeat.test.tsx
@@ -1,10 +1,15 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
-import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
-import { GlobalWindow } from "happy-dom";
-import * as APIModule from "@/browser/contexts/API";
-import * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import type React from "react";
+
+import { APIProvider, type APIClient } from "@/browser/contexts/API";
+import {
+ WorkspaceContext,
+ type WorkspaceContext as WorkspaceContextValue,
+} from "@/browser/contexts/WorkspaceContext";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
-import type { HeartbeatFormSettings } from "./useWorkspaceHeartbeat";
+import { installDom } from "../../../tests/ui/dom";
+import { useWorkspaceHeartbeat, type HeartbeatFormSettings } from "./useWorkspaceHeartbeat";
interface HeartbeatApi {
workspace: {
@@ -29,39 +34,33 @@ const TEST_WORKSPACE_ID = "workspace-1";
type WorkspaceMetadataMap = Map;
type WorkspaceMetadataUpdater = (prev: WorkspaceMetadataMap) => WorkspaceMetadataMap;
-let apiMock: HeartbeatApi | null = null;
let capturedWorkspaceMetadataUpdate: WorkspaceMetadataUpdater | null = null;
const setWorkspaceMetadataMock = mock((update: WorkspaceMetadataUpdater) => {
capturedWorkspaceMetadataUpdate = update;
});
-const actualAPIModule = { ...APIModule };
-const actualWorkspaceContextModule = { ...WorkspaceContextModule };
-
-// Keep module mocks inside test hooks: Bun loads test files before afterAll runs, so
-// file-scope mock.module() calls can pollute unrelated files during collection.
-async function installWorkspaceHeartbeatModuleMocks() {
- await mock.module("@/browser/contexts/API", () => ({
- ...actualAPIModule,
- useAPI: () => ({ api: apiMock }),
- }));
- await mock.module("@/browser/contexts/WorkspaceContext", () => ({
- ...actualWorkspaceContextModule,
- useWorkspaceActions: () => ({
+// Use real providers instead of mock.module(): Bun runs test files in one process, so
+// module-level API/context mocks can leak into unrelated hook tests.
+function createWrapper(api: HeartbeatApi): React.FC<{ children: React.ReactNode }> {
+ return function Wrapper(props) {
+ const workspaceContext = {
+ workspaceMetadata: new Map(),
+ loading: false,
+ loaded: true,
+ loadError: null,
setWorkspaceMetadata: setWorkspaceMetadataMock,
- }),
- }));
-}
-
-async function restoreWorkspaceHeartbeatModuleMocks() {
- // Bun 1.3.6's mock.module() has no disposer, and mock.restore() does not undo
- // module mocks. Restore the real exports so these stubs do not leak into later files.
- await mock.module("@/browser/contexts/API", () => actualAPIModule);
- await mock.module("@/browser/contexts/WorkspaceContext", () => actualWorkspaceContextModule);
+ } as unknown as WorkspaceContextValue;
+
+ return (
+
+
+ {props.children}
+
+
+ );
+ };
}
-import { useWorkspaceHeartbeat } from "./useWorkspaceHeartbeat";
-
function createMetadata(
overrides: Partial = {}
): FrontendWorkspaceMetadata {
@@ -96,36 +95,24 @@ function applyCapturedMetadataUpdate(
}
describe("useWorkspaceHeartbeat", () => {
- let originalWindow: typeof globalThis.window;
- let originalDocument: typeof globalThis.document;
+ let cleanupDom: (() => void) | null = null;
- afterAll(async () => {
- await restoreWorkspaceHeartbeatModuleMocks();
- });
-
- beforeEach(async () => {
- originalWindow = globalThis.window;
- originalDocument = globalThis.document;
- globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
- globalThis.document = globalThis.window.document;
- await installWorkspaceHeartbeatModuleMocks();
+ beforeEach(() => {
+ cleanupDom = installDom();
capturedWorkspaceMetadataUpdate = null;
setWorkspaceMetadataMock.mockClear();
});
- afterEach(async () => {
+ afterEach(() => {
cleanup();
- await restoreWorkspaceHeartbeatModuleMocks();
- mock.restore();
- apiMock = null;
capturedWorkspaceMetadataUpdate = null;
- globalThis.window = originalWindow;
- globalThis.document = originalDocument;
+ cleanupDom?.();
+ cleanupDom = null;
});
test("optimistically enables heartbeat metadata after a successful save", async () => {
const saveHeartbeat = mock(() => Promise.resolve({ success: true }));
- apiMock = {
+ const api: HeartbeatApi = {
workspace: {
heartbeat: {
get: () => Promise.resolve(null),
@@ -137,7 +124,9 @@ describe("useWorkspaceHeartbeat", () => {
},
};
- const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }));
+ const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), {
+ wrapper: createWrapper(api),
+ });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
@@ -182,7 +171,7 @@ describe("useWorkspaceHeartbeat", () => {
contextMode: "compact",
message: "Keep watching",
};
- apiMock = {
+ const api: HeartbeatApi = {
workspace: {
heartbeat: {
get: () => Promise.resolve(initialSettings),
@@ -194,7 +183,9 @@ describe("useWorkspaceHeartbeat", () => {
},
};
- const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }));
+ const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), {
+ wrapper: createWrapper(api),
+ });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
diff --git a/src/cli/run.ts b/src/cli/run.ts
index cf45a0578a..ce6b249a2c 100644
--- a/src/cli/run.ts
+++ b/src/cli/run.ts
@@ -286,6 +286,7 @@ function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["ex
execSubagentHardRestart: experimentIds.includes("exec-subagent-hard-restart"),
dynamicWorkflows: experimentIds.includes("dynamic-workflows"),
subagentFileReports: experimentIds.includes("subagent-file-reports"),
+ workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS),
};
}
diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts
index 3fbfe22d6b..e0b6bac298 100644
--- a/src/cli/workflow.ts
+++ b/src/cli/workflow.ts
@@ -231,6 +231,7 @@ function buildExperimentsObject(experimentIds: readonly string[]) {
// experiment is enabled implicitly for this invocation (never persisted).
dynamicWorkflows: true,
subagentFileReports: experimentIds.includes(EXPERIMENT_IDS.SUBAGENT_FILE_REPORTS),
+ workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS),
};
}
diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts
index 87f2c1c505..2288f1420d 100644
--- a/src/common/orpc/schemas/stream.ts
+++ b/src/common/orpc/schemas/stream.ts
@@ -709,6 +709,7 @@ export const ExperimentsSchema = z.object({
subagentFileReports: z.boolean().optional(),
execSubagentHardRestart: z.boolean().optional(),
memory: z.boolean().optional(),
+ workspaceHeartbeats: z.boolean().optional(),
});
/**
diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts
index 1179e8119f..b498e371eb 100644
--- a/src/common/types/tools.ts
+++ b/src/common/types/tools.ts
@@ -22,6 +22,7 @@ import type {
MuxAgentsReadToolResultSchema,
MuxAgentsWriteToolResultSchema,
FileReadToolResultSchema,
+ HeartbeatToolResultSchema,
MemoryToolResultSchema,
AttachFileToolResultSchema,
TaskToolResultSchema,
@@ -133,6 +134,10 @@ export interface ToolOutputUiOnlyFields {
// FileReadToolResult derived from Zod schema (single source of truth)
export type FileReadToolResult = z.infer;
+// Heartbeat tool types, derived from schema (avoid drift)
+export type HeartbeatToolArgs = z.infer;
+export type HeartbeatToolResult = z.infer;
+
// Memory tool types, derived from schema (avoid drift)
export type MemoryToolArgs = z.infer;
export type MemoryToolResult = z.infer;
diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts
index 04981917f7..4f1a39b4ee 100644
--- a/src/common/utils/tools/toolDefinitions.test.ts
+++ b/src/common/utils/tools/toolDefinitions.test.ts
@@ -229,6 +229,29 @@ describe("TOOL_DEFINITIONS", () => {
}
});
+ it("validates heartbeat tool configuration bounds", () => {
+ expect(TOOL_DEFINITIONS.heartbeat.schema.safeParse({ action: "get" }).success).toBe(true);
+ expect(
+ TOOL_DEFINITIONS.heartbeat.schema.safeParse({
+ action: "set",
+ enabled: true,
+ intervalMs: 5 * 60 * 1000,
+ contextMode: "compact",
+ }).success
+ ).toBe(true);
+ expect(
+ TOOL_DEFINITIONS.heartbeat.schema.safeParse({
+ action: "set",
+ intervalMs: 60 * 1000,
+ }).success
+ ).toBe(false);
+ expect(
+ TOOL_DEFINITIONS.heartbeat.schema.safeParse({
+ action: "configure",
+ }).success
+ ).toBe(false);
+ });
+
it("requires complete_goal summary", () => {
expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({}).success).toBe(false);
expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({ summary: "Done." }).success).toBe(
@@ -443,6 +466,12 @@ describe("TOOL_DEFINITIONS", () => {
expect(tools).toContain("skills_catalog_read");
});
+ it("includes the workspace heartbeat tool", () => {
+ const tools = getAvailableTools("openai:gpt-4o");
+
+ expect(tools).toContain("heartbeat");
+ });
+
it("only includes Review pane tools when enableReviewPane is not disabled", () => {
const defaultTools = getAvailableTools("openai:gpt-4o");
expect(defaultTools).toContain("review_pane_update");
diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts
index 1c78881909..a68a0f5397 100644
--- a/src/common/utils/tools/toolDefinitions.ts
+++ b/src/common/utils/tools/toolDefinitions.ts
@@ -39,6 +39,7 @@ import {
WorkflowRunRecordSchema,
WorkflowRunStatusSchema,
WorkflowStepStatusSchema,
+ WorkspaceHeartbeatSettingsSchema,
} from "@/common/orpc/schemas";
import {
RUNTIME_MODE,
@@ -63,6 +64,12 @@ import { zodToJsonSchema } from "zod-to-json-schema";
import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath";
import { TASK_VARIANT_PLACEHOLDER, TASK_GROUP_KIND_VALUES } from "@/common/utils/tools/taskGroups";
+import {
+ HEARTBEAT_CONTEXT_MODE_VALUES,
+ HEARTBEAT_MAX_INTERVAL_MS,
+ HEARTBEAT_MIN_INTERVAL_MS,
+} from "@/constants/heartbeat";
+
// -----------------------------------------------------------------------------
// ask_user_question (plan-mode interactive questions)
// -----------------------------------------------------------------------------
@@ -164,6 +171,46 @@ export const AskUserQuestionToolResultSchema = z.union([
AskUserQuestionToolLegacySchema,
]);
+// -----------------------------------------------------------------------------
+// heartbeat (workspace idle check-in schedule)
+// -----------------------------------------------------------------------------
+
+export const HeartbeatToolActionSchema = z.enum(["get", "set", "unset"]);
+export const HeartbeatToolArgsSchema = z
+ .object({
+ action: HeartbeatToolActionSchema.describe(
+ 'Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace\'s heartbeat settings.'
+ ),
+ enabled: z
+ .boolean()
+ .nullish()
+ .describe(
+ 'set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled".'
+ ),
+ intervalMs: z
+ .number()
+ .int()
+ .min(HEARTBEAT_MIN_INTERVAL_MS)
+ .max(HEARTBEAT_MAX_INTERVAL_MS)
+ .nullish()
+ .describe(
+ `set: heartbeat interval in milliseconds (${HEARTBEAT_MIN_INTERVAL_MS}–${HEARTBEAT_MAX_INTERVAL_MS}). Omit to preserve the current interval or use the global default for new settings.`
+ ),
+ message: z
+ .string()
+ .nullish()
+ .describe(
+ "set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message."
+ ),
+ contextMode: z
+ .enum(HEARTBEAT_CONTEXT_MODE_VALUES)
+ .nullish()
+ .describe(
+ 'set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode.'
+ ),
+ })
+ .strict();
+
// -----------------------------------------------------------------------------
// advisor (nested strategic guidance)
// -----------------------------------------------------------------------------
@@ -1910,6 +1957,15 @@ export const TOOL_DEFINITIONS = {
.strict(),
},
+ heartbeat: {
+ description:
+ "Read or change this workspace's scheduled heartbeat. " +
+ "The tool only affects the current workspace; it does not accept a workspaceId. " +
+ "Use action='set' to enable or configure the heartbeat interval, custom message, context mode, or enabled flag. " +
+ "Use action='unset' to remove this workspace's heartbeat settings entirely. " +
+ "Use action='get' before changing settings when you need to preserve existing values.",
+ schema: HeartbeatToolArgsSchema,
+ },
todo_write: {
description:
"Create or update the todo list for tracking multi-step tasks (limit: 7 items). " +
@@ -2522,6 +2578,20 @@ export const WebFetchToolResultSchema = z.union([
}),
]);
+export const HeartbeatToolResultSchema = z.union([
+ z.object({
+ success: z.literal(true),
+ action: HeartbeatToolActionSchema,
+ configured: z.boolean(),
+ settings: WorkspaceHeartbeatSettingsSchema.nullable(),
+ summary: z.string(),
+ }),
+ z.object({
+ success: z.literal(false),
+ error: z.string(),
+ }),
+]);
+
export const MemoryToolResultSchema = z.union([
z.object({
success: z.literal(true),
@@ -2557,6 +2627,7 @@ export type BridgeableToolName =
| "task_apply_git_patch"
| "task_list"
| "task_terminate"
+ | "heartbeat"
| "memory";
/**
@@ -2582,6 +2653,7 @@ export const RESULT_SCHEMAS: Record = {
task_apply_git_patch: TaskApplyGitPatchToolResultSchema,
task_list: TaskListToolResultSchema,
task_terminate: TaskTerminateToolResultSchema,
+ heartbeat: HeartbeatToolResultSchema,
memory: MemoryToolResultSchema,
};
@@ -2700,6 +2772,7 @@ export function getAvailableTools(
...(enableAgentReport ? ["agent_report"] : []),
"get_goal",
"complete_goal",
+ "heartbeat",
"todo_write",
"todo_read",
...(enableReviewPane ? ["review_pane_update", "review_pane_get"] : []),
diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts
index 8004875a5f..810cc88c2b 100644
--- a/src/common/utils/tools/tools.test.ts
+++ b/src/common/utils/tools/tools.test.ts
@@ -2,10 +2,11 @@
import { describe, expect, mock, test } from "bun:test";
import { z } from "zod";
+import { Ok } from "@/common/types/result";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager";
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
-import { getToolsForModel } from "./tools";
+import { getToolsForModel, type WorkspaceHeartbeatToolService } from "./tools";
const DESKTOP_TOOL_NAMES = [
"desktop_screenshot",
@@ -90,6 +91,78 @@ describe("getToolsForModel", () => {
expect(toolsWithReport.agent_report).toBeDefined();
});
+ test("includes heartbeat only when the heartbeat service and experiment are configured", async () => {
+ const runtime = new LocalRuntime(process.cwd());
+ const initStateManager = createInitStateManager();
+
+ const toolsWithoutHeartbeat = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ workspaceId: "ws-1",
+ experiments: { workspaceHeartbeats: true },
+ },
+ "ws-1",
+ initStateManager
+ );
+ expect(toolsWithoutHeartbeat.heartbeat).toBeUndefined();
+
+ const heartbeatService: WorkspaceHeartbeatToolService = {
+ getHeartbeatSettings: mock(() => null),
+ setHeartbeatSettings: mock(() =>
+ Promise.resolve(
+ Ok({ enabled: true, intervalMs: 30 * 60 * 1000, contextMode: "normal" as const })
+ )
+ ),
+ unsetHeartbeatSettings: mock(() => Promise.resolve(Ok(undefined))),
+ };
+ const toolsWithExperimentDisabled = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ workspaceId: "ws-1",
+ workspaceHeartbeatService: heartbeatService,
+ },
+ "ws-1",
+ initStateManager
+ );
+ expect(toolsWithExperimentDisabled.heartbeat).toBeUndefined();
+
+ const toolsWithHeartbeat = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ workspaceId: "ws-1",
+ experiments: { workspaceHeartbeats: true },
+ workspaceHeartbeatService: heartbeatService,
+ },
+ "ws-1",
+ initStateManager
+ );
+ const childToolsWithHeartbeat = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ workspaceId: "child-ws",
+ enableAgentReport: true,
+ experiments: { workspaceHeartbeats: true },
+ workspaceHeartbeatService: heartbeatService,
+ },
+ "child-ws",
+ initStateManager
+ );
+ expect(childToolsWithHeartbeat.heartbeat).toBeUndefined();
+ expect(toolsWithHeartbeat.heartbeat).toBeDefined();
+ });
+
test("withholds review_pane_* tools from sub-agents (enableAgentReport=true)", async () => {
const runtime = new LocalRuntime(process.cwd());
const initStateManager = createInitStateManager();
diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts
index 04f867ac0b..70d2fdb58b 100644
--- a/src/common/utils/tools/tools.ts
+++ b/src/common/utils/tools/tools.ts
@@ -18,6 +18,7 @@ import {
createReviewPaneUpdateTool,
createReviewPaneGetTool,
} from "@/node/services/tools/review_pane";
+import { createHeartbeatTool } from "@/node/services/tools/heartbeat";
import { createGetGoalTool } from "@/node/services/tools/get_goal";
import { createCompleteGoalTool } from "@/node/services/tools/complete_goal";
import { createNotifyTool } from "@/node/services/tools/notify";
@@ -60,6 +61,7 @@ import {
} from "@/common/utils/tools/toolDefinitions";
import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer";
+import type { Result } from "@/common/types/result";
import type { Runtime } from "@/node/runtime/Runtime";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
@@ -74,7 +76,7 @@ import type { FileState } from "@/node/services/agentSession";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { ModelMessage } from "@/common/types/message";
-import type { ProjectRef } from "@/common/types/workspace";
+import type { ProjectRef, WorkspaceMetadata } from "@/common/types/workspace";
export interface ToolModelUsageEvent {
source: "tool";
@@ -100,6 +102,18 @@ export interface AdvisorStepCaptureRef {
frozenSnapshotsByToolCallId: Map;
}
+export type WorkspaceHeartbeatSettings = NonNullable;
+export type WorkspaceHeartbeatSettingsUpdate = Partial;
+
+export interface WorkspaceHeartbeatToolService {
+ getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null;
+ setHeartbeatSettings(
+ workspaceId: string,
+ settings: WorkspaceHeartbeatSettingsUpdate
+ ): Promise>;
+ unsetHeartbeatSettings(workspaceId: string): Promise>;
+}
+
/**
* Configuration for tools that need runtime context
*/
@@ -218,6 +232,8 @@ export interface ToolConfiguration {
projectTrusted: boolean;
}): Promise<{ runId: string; status: string; result: unknown }>;
};
+ /** Workspace heartbeat settings service for model-facing heartbeat configuration. */
+ workspaceHeartbeatService?: WorkspaceHeartbeatToolService;
/** Workspace goal lifecycle service for model-facing goal tools. */
goalService?: WorkspaceGoalService;
/** Per-request goal tool gates derived from goal status and agent capabilities. */
@@ -240,6 +256,7 @@ export interface ToolConfiguration {
dynamicWorkflows?: boolean;
subagentFileReports?: boolean;
memory?: boolean;
+ workspaceHeartbeats?: boolean;
};
/** Available sub-agents for the task tool description (dynamic context) */
availableSubagents?: AgentDefinitionDescriptor[];
@@ -533,6 +550,13 @@ export async function getToolsForModel(
: {}),
};
+ // HeartbeatService intentionally skips child task workspaces, and the
+ // workspace-heartbeats experiment gates every user-facing way to create schedules.
+ const shouldExposeHeartbeatTool =
+ config.workspaceHeartbeatService != null &&
+ config.experiments?.workspaceHeartbeats === true &&
+ !config.enableAgentReport;
+
// Non-runtime tools execute immediately (no init wait needed)
// Note: Tool availability is controlled by agent tool policy (allowlist), not mode checks here.
const nonRuntimeTools: Record = {
@@ -564,6 +588,7 @@ export async function getToolsForModel(
}
: {}),
...(config.enableAgentReport ? { agent_report: createAgentReportTool(config) } : {}),
+ ...(shouldExposeHeartbeatTool ? { heartbeat: createHeartbeatTool(config) } : {}),
...(config.goalService && config.enableGoalTools?.getGoal
? { get_goal: createGetGoalTool(config) }
: {}),
diff --git a/src/node/config.test.ts b/src/node/config.test.ts
index b4aaef9f81..7c904606d5 100644
--- a/src/node/config.test.ts
+++ b/src/node/config.test.ts
@@ -12,6 +12,7 @@ import {
} from "@/common/config/worktreeArchiveBehavior";
import { KNOWN_MODELS } from "@/common/constants/knownModels";
import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject";
+import type { WorkspaceMetadata } from "@/common/types/workspace";
import { type ExternalSecretResolver, secretsToRecord } from "@/common/types/secrets";
describe("Config", () => {
@@ -1576,6 +1577,37 @@ describe("Config", () => {
expect(workspace.name).toBe("feature-branch");
});
+ it("defaults sparse persisted heartbeat intervals in workspace metadata", async () => {
+ const projectPath = "/fake/project";
+ const workspacePath = path.join(config.srcDir, "project", "heartbeat-sparse");
+ const sparseHeartbeat = { enabled: true } as const;
+
+ await config.editConfig((cfg) => {
+ cfg.heartbeatDefaultIntervalMs = 45 * 60 * 1000;
+ cfg.projects.set(projectPath, {
+ workspaces: [
+ {
+ path: workspacePath,
+ id: "workspace-heartbeat-sparse",
+ name: "heartbeat-sparse",
+ createdAt: "2025-01-01T00:00:00.000Z",
+ runtimeConfig: { type: "local" },
+ // Simulates older/corrupt persisted config; workspace metadata must stay schema-valid.
+ heartbeat: sparseHeartbeat as NonNullable,
+ },
+ ],
+ });
+ return cfg;
+ });
+
+ const [metadata] = await config.getAllWorkspaceMetadata();
+
+ expect(metadata.heartbeat).toEqual({
+ enabled: true,
+ intervalMs: 45 * 60 * 1000,
+ });
+ });
+
it("should use existing metadata file if present (legacy format)", async () => {
const projectPath = "/fake/project";
const workspaceName = "my-feature";
diff --git a/src/node/config.ts b/src/node/config.ts
index 838d6360f1..5bf1d4c8d9 100644
--- a/src/node/config.ts
+++ b/src/node/config.ts
@@ -56,7 +56,12 @@ import {
type WorktreeArchiveBehavior,
} from "@/common/config/worktreeArchiveBehavior";
import { PlatformPaths } from "@/common/utils/paths";
-import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat";
+import {
+ HEARTBEAT_CONTEXT_MODE_VALUES,
+ HEARTBEAT_DEFAULT_INTERVAL_MS,
+ HEARTBEAT_MAX_INTERVAL_MS,
+ HEARTBEAT_MIN_INTERVAL_MS,
+} from "@/constants/heartbeat";
import { normalizeGoalDefaults } from "@/constants/goals";
import {
isValidModelFormat,
@@ -78,6 +83,51 @@ import {
export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig };
export type ProvidersConfig = CanonicalProvidersConfig | Record;
+function isValidHeartbeatIntervalMs(intervalMs: unknown): intervalMs is number {
+ return (
+ typeof intervalMs === "number" &&
+ Number.isInteger(intervalMs) &&
+ intervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
+ intervalMs <= HEARTBEAT_MAX_INTERVAL_MS
+ );
+}
+
+function isWorkspaceHeartbeatContextMode(
+ value: unknown
+): value is NonNullable["contextMode"]> {
+ return (
+ typeof value === "string" &&
+ HEARTBEAT_CONTEXT_MODE_VALUES.some((candidate) => candidate === value)
+ );
+}
+
+function normalizeWorkspaceMetadataHeartbeat(
+ heartbeat: Workspace["heartbeat"] | undefined,
+ config: ProjectsConfig
+): WorkspaceMetadata["heartbeat"] | undefined {
+ if (!heartbeat) {
+ return undefined;
+ }
+
+ const persisted = heartbeat as Partial>;
+ const defaultIntervalMs = isValidHeartbeatIntervalMs(config.heartbeatDefaultIntervalMs)
+ ? config.heartbeatDefaultIntervalMs
+ : HEARTBEAT_DEFAULT_INTERVAL_MS;
+ const message = typeof persisted.message === "string" ? persisted.message : undefined;
+ const contextMode = isWorkspaceHeartbeatContextMode(persisted.contextMode)
+ ? persisted.contextMode
+ : undefined;
+
+ return {
+ enabled: persisted.enabled === true,
+ intervalMs: isValidHeartbeatIntervalMs(persisted.intervalMs)
+ ? persisted.intervalMs
+ : defaultIntervalMs,
+ ...(message != null ? { message } : {}),
+ ...(contextMode != null ? { contextMode } : {}),
+ };
+}
+
function parseOptionalNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
@@ -1663,7 +1713,7 @@ export class Config {
// GUARANTEE: All workspaces must have runtimeConfig (apply default if missing)
runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG,
aiSettings: workspace.aiSettings,
- heartbeat: workspace.heartbeat,
+ heartbeat: normalizeWorkspaceMetadataHeartbeat(workspace.heartbeat, config),
workflowSchedule: parsePersistedWorkflowSchedule(workspace.workflowSchedule),
goalDefaults: workspace.goalDefaults,
aiSettingsByAgent:
diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts
index 1f767b1495..68f45114ce 100644
--- a/src/node/orpc/router.ts
+++ b/src/node/orpc/router.ts
@@ -4251,14 +4251,18 @@ export const router = (authToken?: string) => {
set: t
.input(schemas.workspace.heartbeat.set.input)
.output(schemas.workspace.heartbeat.set.output)
- .handler(({ context, input }) =>
- context.workspaceService.setHeartbeatSettings(input.workspaceId, {
+ .handler(async ({ context, input }) => {
+ const result = await context.workspaceService.setHeartbeatSettings(input.workspaceId, {
enabled: input.enabled,
intervalMs: input.intervalMs,
...(input.message != null ? { message: input.message } : {}),
...(input.contextMode != null ? { contextMode: input.contextMode } : {}),
- })
- ),
+ });
+ if (!result.success) {
+ return result;
+ }
+ return Ok(undefined);
+ }),
},
goalDefaults: {
// Per-workspace override of the global `goalDefaults` block.
diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts
index ca29a53683..5a9819e792 100644
--- a/src/node/services/agentSkills/builtInSkillContent.generated.ts
+++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts
@@ -4448,6 +4448,19 @@ export const BUILTIN_SKILL_FILES: Record> = {
" ",
"",
"",
+ "heartbeat (5)
",
+ "",
+ "| Env var | JSON path | Type | Description |",
+ "| ----------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |",
+ '| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace\'s heartbeat settings. |',
+ '| `MUX_TOOL_INPUT_CONTEXT_MODE` | `contextMode` | enum | set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode. |',
+ '| `MUX_TOOL_INPUT_ENABLED` | `enabled` | boolean | set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled". |',
+ "| `MUX_TOOL_INPUT_INTERVAL_MS` | `intervalMs` | number | set: heartbeat interval in milliseconds (300000–86400000). Omit to preserve the current interval or use the global default for new settings. |",
+ "| `MUX_TOOL_INPUT_MESSAGE` | `message` | string | set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message. |",
+ "",
+ " ",
+ "",
+ "",
"memory (11)
",
"",
"| Env var | JSON path | Type | Description |",
diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts
index 0bc60a29cd..76022bc593 100644
--- a/src/node/services/aiService.ts
+++ b/src/node/services/aiService.ts
@@ -445,6 +445,7 @@ export class AIService extends EventEmitter {
event: WorkflowRunStatusChangedEvent
) => Promise | void;
private workflowResultContinuationSender?: WorkflowResultContinuationSender;
+ private workspaceHeartbeatService?: ToolConfiguration["workspaceHeartbeatService"];
private analyticsService?: { executeRawQuery(sql: string): Promise };
private desktopSessionManager?: DesktopSessionManager;
@@ -512,6 +513,12 @@ export class AIService extends EventEmitter {
this.taskService = taskService;
}
+ setWorkspaceHeartbeatService(
+ service: NonNullable
+ ): void {
+ this.workspaceHeartbeatService = service;
+ }
+
/**
* Host actions (workspace.*) for workflow runners. Built once in coreServices
* — which owns WorkspaceService/HistoryService/Config — and injected here so
@@ -1346,6 +1353,9 @@ export class AIService extends EventEmitter {
const memoryExperimentEnabled =
experiments?.memory ??
this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true;
+ const workspaceHeartbeatsExperimentEnabled =
+ experiments?.workspaceHeartbeats ??
+ this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS) === true;
const memoryHotSetExperimentEnabled =
this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === true;
// Once final tool policy keeps the memory tool, upgrade the index-only
@@ -1795,6 +1805,7 @@ export class AIService extends EventEmitter {
...experiments,
dynamicWorkflows: dynamicWorkflowsExperimentEnabled,
subagentFileReports: subagentFileReportsExperimentEnabled,
+ workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
},
}),
// Background workflow tools outlive the model turn that started them. Feed the
@@ -1848,6 +1859,7 @@ export class AIService extends EventEmitter {
...experiments,
dynamicWorkflows: dynamicWorkflowsExperimentEnabled,
subagentFileReports: subagentFileReportsExperimentEnabled,
+ workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
},
skipAiSettingsPersistence: true,
muxMetadata: {
@@ -1979,6 +1991,7 @@ export class AIService extends EventEmitter {
ancestorPlanFilePaths,
workspaceId,
muxScope,
+ workspaceHeartbeatService: this.workspaceHeartbeatService,
workflowService,
goalService: workspaceGoalService,
enableGoalTools: goalToolAvailability,
@@ -2075,6 +2088,7 @@ export class AIService extends EventEmitter {
dynamicWorkflows: dynamicWorkflowsExperimentEnabled,
subagentFileReports: subagentFileReportsExperimentEnabled,
memory: memoryExperimentEnabled,
+ workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
},
// Dynamic context for tool descriptions (moved from system prompt for better model attention)
availableSubagents: agentDefinitions,
diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts
index 73bc83520a..fd2393681e 100644
--- a/src/node/services/coreServices.ts
+++ b/src/node/services/coreServices.ts
@@ -160,6 +160,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices {
opts.sessionTimingService,
opts.opResolver
);
+ aiService.setWorkspaceHeartbeatService(workspaceService);
// Tool-started workflows share the same sidebar activity cache as ORPC-started workflows,
// so terminal updates must prune active run counts regardless of launch path.
aiService.setWorkflowRunStatusChangedHandler((event) =>
diff --git a/src/node/services/ptc/typeGenerator.test.ts b/src/node/services/ptc/typeGenerator.test.ts
index 5801bb5c2e..f401e3a4f1 100644
--- a/src/node/services/ptc/typeGenerator.test.ts
+++ b/src/node/services/ptc/typeGenerator.test.ts
@@ -220,6 +220,14 @@ describe("generateMuxTypes", () => {
expect(types).not.toContain("function file_read(args: FileReadArgs): unknown");
});
+ test("heartbeat tool gets a typed result so sandbox code can check result.success", async () => {
+ const types = await generateMuxTypes({
+ heartbeat: createMockTool(z.object({ action: z.string() })),
+ });
+ expect(types).toContain("HeartbeatResult");
+ expect(types).not.toContain("function heartbeat(args: HeartbeatArgs): unknown");
+ });
+
test("memory tool gets a typed result so sandbox code can check result.success", async () => {
const types = await generateMuxTypes({
memory: createMockTool(z.object({ command: z.string() })),
diff --git a/src/node/services/tools/heartbeat.test.ts b/src/node/services/tools/heartbeat.test.ts
new file mode 100644
index 0000000000..45f992ed51
--- /dev/null
+++ b/src/node/services/tools/heartbeat.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, mock, test } from "bun:test";
+
+import { Ok } from "@/common/types/result";
+import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools";
+import type { WorkspaceMetadata } from "@/common/types/workspace";
+import type {
+ WorkspaceHeartbeatSettingsUpdate,
+ WorkspaceHeartbeatToolService,
+} from "@/common/utils/tools/tools";
+import { HEARTBEAT_DEFAULT_CONTEXT_MODE } from "@/constants/heartbeat";
+import { createHeartbeatTool } from "./heartbeat";
+import { createTestToolConfig, mockToolCallOptions, TestTempDir } from "./testHelpers";
+
+type WorkspaceHeartbeatSettings = NonNullable;
+
+function createService(initial: WorkspaceHeartbeatSettings | null = null): {
+ service: WorkspaceHeartbeatToolService;
+ getStored: () => WorkspaceHeartbeatSettings | null;
+} {
+ let stored = initial;
+ const service: WorkspaceHeartbeatToolService = {
+ getHeartbeatSettings: mock((workspaceId: string) => {
+ expect(workspaceId).toBe("ws-heartbeat");
+ return stored;
+ }),
+ setHeartbeatSettings: mock(
+ (workspaceId: string, settings: WorkspaceHeartbeatSettingsUpdate) => {
+ expect(workspaceId).toBe("ws-heartbeat");
+ const hasMessageUpdate = Object.prototype.hasOwnProperty.call(settings, "message");
+ const nextMessage = hasMessageUpdate ? settings.message?.trim() : stored?.message;
+ stored = {
+ enabled: settings.enabled ?? stored?.enabled ?? true,
+ intervalMs: settings.intervalMs ?? stored?.intervalMs ?? 15 * 60 * 1000,
+ contextMode:
+ settings.contextMode ?? stored?.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE,
+ ...(nextMessage ? { message: nextMessage } : {}),
+ };
+ return Promise.resolve(Ok(stored));
+ }
+ ),
+ unsetHeartbeatSettings: mock((workspaceId: string) => {
+ expect(workspaceId).toBe("ws-heartbeat");
+ stored = null;
+ return Promise.resolve(Ok(undefined));
+ }),
+ };
+ return { service, getStored: () => stored };
+}
+
+async function execute(
+ service: WorkspaceHeartbeatToolService | undefined,
+ args: HeartbeatToolArgs
+): Promise {
+ using tempDir = new TestTempDir("heartbeat-tool-test");
+ const tool = createHeartbeatTool({
+ ...createTestToolConfig(tempDir.path, { workspaceId: "ws-heartbeat" }),
+ ...(service ? { workspaceHeartbeatService: service } : {}),
+ });
+ const result: unknown = await Promise.resolve(tool.execute!(args, mockToolCallOptions));
+ return result as HeartbeatToolResult;
+}
+
+describe("heartbeat tool", () => {
+ test("gets current heartbeat settings for the owning workspace", async () => {
+ const current = {
+ enabled: true,
+ intervalMs: 30 * 60 * 1000,
+ contextMode: "compact" as const,
+ message: "Review idle work.",
+ };
+ const { service } = createService(current);
+
+ const result = await execute(service, { action: "get" });
+
+ expect(result).toEqual({
+ success: true,
+ action: "get",
+ configured: true,
+ settings: current,
+ summary: "Heartbeat is enabled for this workspace at 30 minutes.",
+ });
+ });
+
+ test("set creates an enabled heartbeat from global defaults", async () => {
+ const { service, getStored } = createService(null);
+
+ const result = await execute(service, { action: "set" });
+
+ expect(result.success).toBe(true);
+ expect(getStored()).toEqual({
+ enabled: true,
+ intervalMs: 15 * 60 * 1000,
+ contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
+ });
+ });
+
+ test("set updates only provided fields and can clear the custom message", async () => {
+ const { service, getStored } = createService({
+ enabled: true,
+ intervalMs: 30 * 60 * 1000,
+ contextMode: "reset",
+ message: "Old custom prompt",
+ });
+
+ const result = await execute(service, {
+ action: "set",
+ intervalMs: 45 * 60 * 1000,
+ message: "",
+ });
+
+ expect(result.success).toBe(true);
+ expect(getStored()).toEqual({
+ enabled: true,
+ intervalMs: 45 * 60 * 1000,
+ contextMode: "reset",
+ });
+ });
+
+ test("unset removes heartbeat settings", async () => {
+ const { service, getStored } = createService({
+ enabled: true,
+ intervalMs: 30 * 60 * 1000,
+ contextMode: "normal",
+ });
+
+ const result = await execute(service, { action: "unset" });
+
+ expect(result).toEqual({
+ success: true,
+ action: "unset",
+ configured: false,
+ settings: null,
+ summary: "Heartbeat settings removed for this workspace.",
+ });
+ expect(getStored()).toBeNull();
+ });
+
+ test("returns a typed error when the service is unavailable", async () => {
+ const result = await execute(undefined, { action: "get" });
+
+ expect(result).toEqual({ success: false, error: "Heartbeat service is unavailable" });
+ });
+});
diff --git a/src/node/services/tools/heartbeat.ts b/src/node/services/tools/heartbeat.ts
new file mode 100644
index 0000000000..6330271b1a
--- /dev/null
+++ b/src/node/services/tools/heartbeat.ts
@@ -0,0 +1,123 @@
+import { tool } from "ai";
+import assert from "@/common/utils/assert";
+import type { ToolFactory, WorkspaceHeartbeatSettingsUpdate } from "@/common/utils/tools/tools";
+import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions";
+import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools";
+import { getErrorMessage } from "@/common/utils/errors";
+import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat";
+import { requireWorkspaceId } from "./toolUtils";
+
+function hasProvided(
+ args: HeartbeatToolArgs,
+ key: K
+): args is HeartbeatToolArgs & { [P in K]-?: NonNullable } {
+ return Object.prototype.hasOwnProperty.call(args, key) && args[key] != null;
+}
+
+function formatInterval(intervalMs: number): string {
+ assert(
+ Number.isInteger(intervalMs) &&
+ intervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
+ intervalMs <= HEARTBEAT_MAX_INTERVAL_MS,
+ "formatInterval requires a supported heartbeat interval"
+ );
+
+ const minuteMs = 60 * 1000;
+ const hourMs = 60 * minuteMs;
+ if (intervalMs % hourMs === 0) {
+ const hours = intervalMs / hourMs;
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
+ }
+ if (intervalMs % minuteMs === 0) {
+ const minutes = intervalMs / minuteMs;
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
+ }
+ return `${intervalMs} ms`;
+}
+
+function summarize(
+ result: Pick
+): string {
+ if (result.action === "unset") {
+ return "Heartbeat settings removed for this workspace.";
+ }
+
+ const settings = result.settings;
+ if (!settings) {
+ return "No heartbeat settings are configured for this workspace.";
+ }
+
+ const status = settings.enabled ? "enabled" : "disabled";
+ return `Heartbeat is ${status} for this workspace at ${formatInterval(settings.intervalMs)}.`;
+}
+
+export const createHeartbeatTool: ToolFactory = (config) =>
+ tool({
+ description: TOOL_DEFINITIONS.heartbeat.description,
+ inputSchema: TOOL_DEFINITIONS.heartbeat.schema,
+ execute: async (args): Promise => {
+ try {
+ const workspaceId = requireWorkspaceId(config, "heartbeat");
+
+ const heartbeatService = config.workspaceHeartbeatService;
+ if (!heartbeatService) {
+ return { success: false, error: "Heartbeat service is unavailable" };
+ }
+
+ if (args.action === "get") {
+ const settings = heartbeatService.getHeartbeatSettings(workspaceId);
+ return {
+ success: true,
+ action: args.action,
+ configured: settings != null,
+ settings,
+ summary: summarize({ action: args.action, settings }),
+ };
+ }
+
+ if (args.action === "unset") {
+ const unsetResult = await heartbeatService.unsetHeartbeatSettings(workspaceId);
+ if (!unsetResult.success) {
+ return { success: false, error: unsetResult.error };
+ }
+ return {
+ success: true,
+ action: args.action,
+ configured: false,
+ settings: null,
+ summary: summarize({ action: args.action, settings: null }),
+ };
+ }
+
+ const settingsUpdate: WorkspaceHeartbeatSettingsUpdate = {};
+ if (hasProvided(args, "enabled")) {
+ settingsUpdate.enabled = args.enabled;
+ }
+ if (hasProvided(args, "intervalMs")) {
+ settingsUpdate.intervalMs = args.intervalMs;
+ }
+ if (hasProvided(args, "contextMode")) {
+ settingsUpdate.contextMode = args.contextMode;
+ }
+ if (hasProvided(args, "message")) {
+ settingsUpdate.message = args.message;
+ }
+
+ const setResult = await heartbeatService.setHeartbeatSettings(workspaceId, settingsUpdate);
+ if (!setResult.success) {
+ return { success: false, error: setResult.error };
+ }
+
+ const settings = setResult.data;
+ return {
+ success: true,
+ action: args.action,
+ configured: true,
+ settings,
+ summary: summarize({ action: args.action, settings }),
+ };
+ } catch (error) {
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ });
diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts
index f9478c62a1..e7bddb0930 100644
--- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts
+++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts
@@ -21,6 +21,7 @@ interface WorkflowTaskExperiments {
programmaticToolCallingExclusive?: boolean;
advisorTool?: boolean;
execSubagentHardRestart?: boolean;
+ workspaceHeartbeats?: boolean;
dynamicWorkflows?: boolean;
subagentFileReports?: boolean;
}
diff --git a/src/node/services/workspaceService.heartbeatSettings.test.ts b/src/node/services/workspaceService.heartbeatSettings.test.ts
index bf784b4d14..88d8d61e9e 100644
--- a/src/node/services/workspaceService.heartbeatSettings.test.ts
+++ b/src/node/services/workspaceService.heartbeatSettings.test.ts
@@ -135,6 +135,27 @@ describe("WorkspaceService heartbeat settings", () => {
expect(updateRecencyTimestamp).not.toHaveBeenCalled();
});
+ test("unsets heartbeat settings and updates workspace recency", async () => {
+ const updateRecencyTimestamp = mock<(workspaceId: string, timestamp?: number) => Promise>(
+ () => Promise.resolve()
+ );
+ (
+ service as unknown as {
+ updateRecencyTimestamp: (workspaceId: string, timestamp?: number) => Promise;
+ }
+ ).updateRecencyTimestamp = updateRecencyTimestamp;
+
+ const result = await service.unsetHeartbeatSettings(TEST_WORKSPACE_ID);
+
+ expect(result.success).toBe(true);
+ const persistedHeartbeat = currentProjectsConfig.projects
+ .get(TEST_PROJECT_PATH)
+ ?.workspaces.at(0)?.heartbeat;
+ expect(persistedHeartbeat).toBeUndefined();
+ expect(service.getHeartbeatSettings(TEST_WORKSPACE_ID)).toBeNull();
+ expect(updateRecencyTimestamp).toHaveBeenCalledTimes(1);
+ });
+
test("preserves the existing message when a write omits the message field", async () => {
const result = await service.setHeartbeatSettings(TEST_WORKSPACE_ID, {
enabled: true,
@@ -207,6 +228,24 @@ describe("WorkspaceService heartbeat settings", () => {
});
});
+ test("defaults sparse persisted heartbeat intervals to the global default on read", () => {
+ currentProjectsConfig.heartbeatDefaultIntervalMs = 45 * 60 * 1000;
+ const persistedHeartbeat = currentProjectsConfig.projects
+ .get(TEST_PROJECT_PATH)
+ ?.workspaces.at(0)?.heartbeat as { intervalMs?: number } | undefined;
+ if (!persistedHeartbeat) {
+ throw new Error("Expected persisted heartbeat settings");
+ }
+ delete persistedHeartbeat.intervalMs;
+
+ expect(service.getHeartbeatSettings(TEST_WORKSPACE_ID)).toEqual({
+ enabled: true,
+ intervalMs: 45 * 60 * 1000,
+ message: "Keep this custom heartbeat message.",
+ contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
+ });
+ });
+
test("persists an explicit heartbeat context mode", async () => {
const result = await service.setHeartbeatSettings(TEST_WORKSPACE_ID, {
enabled: true,
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index 3df4092952..d9a4089eb9 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -8,7 +8,7 @@ import { isWorkspaceArchived } from "@/common/utils/archive";
import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import type { Config } from "@/node/config";
-import type { Workspace } from "@/common/types/project";
+import type { ProjectsConfig, Workspace } from "@/common/types/project";
import type { Result } from "@/common/types/result";
import { Ok, Err } from "@/common/types/result";
import { normalizeTaskSettings } from "@/common/types/tasks";
@@ -265,7 +265,13 @@ const AUTO_NEW_WORKSPACE_BASE_NAME = "workspace";
// Shared type for workspace-scoped AI settings (model + thinking)
type WorkspaceAISettings = z.infer;
type WorkspaceHeartbeatSettings = z.infer;
+type WorkspaceHeartbeatSettingsUpdate = Partial;
type WorkspaceGoalDefaultsOverride = z.infer;
+interface HeartbeatWorkspaceConfigEntry {
+ normalizedWorkspaceId: string;
+ config: ProjectsConfig;
+ workspaceEntry: Workspace;
+}
interface HeartbeatExecutionRequest {
contextMode: HeartbeatContextMode;
sendOptions: SendMessageOptions;
@@ -422,6 +428,43 @@ function sanitizeHeartbeatContextMode(value: unknown): HeartbeatContextMode {
return isHeartbeatContextMode(value) ? value : HEARTBEAT_DEFAULT_CONTEXT_MODE;
}
+function sanitizeHeartbeatIntervalMs(intervalMs: unknown, defaultIntervalMs: number): number {
+ assert(
+ Number.isInteger(defaultIntervalMs) &&
+ defaultIntervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
+ defaultIntervalMs <= HEARTBEAT_MAX_INTERVAL_MS,
+ "sanitizeHeartbeatIntervalMs requires a supported default interval"
+ );
+
+ if (
+ typeof intervalMs === "number" &&
+ Number.isInteger(intervalMs) &&
+ intervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
+ intervalMs <= HEARTBEAT_MAX_INTERVAL_MS
+ ) {
+ return intervalMs;
+ }
+
+ return defaultIntervalMs;
+}
+
+function normalizeHeartbeatSettings(
+ settings: Partial | null | undefined,
+ defaultIntervalMs: number
+): WorkspaceHeartbeatSettings | null {
+ if (!settings) {
+ return null;
+ }
+
+ const message = sanitizeHeartbeatMessage(settings.message);
+ return {
+ enabled: settings.enabled === true,
+ intervalMs: sanitizeHeartbeatIntervalMs(settings.intervalMs, defaultIntervalMs),
+ contextMode: sanitizeHeartbeatContextMode(settings.contextMode),
+ ...(message != null ? { message } : {}),
+ };
+}
+
interface WorkspaceAgentStatus {
emoji: string;
message: string;
@@ -3975,52 +4018,110 @@ export class WorkspaceService extends EventEmitter {
return this.enrichMaybeFrontendMetadata(found);
}
- getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null {
+ private resolveHeartbeatWorkspaceEntry(
+ workspaceId: string,
+ methodName: "getHeartbeatSettings" | "setHeartbeatSettings" | "unsetHeartbeatSettings"
+ ): Result {
const normalizedWorkspaceId = workspaceId.trim();
- assert(
- normalizedWorkspaceId.length > 0,
- "getHeartbeatSettings requires a non-empty workspaceId"
- );
+ assert(normalizedWorkspaceId.length > 0, `${methodName} requires a non-empty workspaceId`);
const found = this.config.findWorkspace(normalizedWorkspaceId);
if (!found) {
- return null;
+ return Err("Workspace not found");
}
const config = this.config.loadConfigOrDefault();
const projectConfig = config.projects.get(found.projectPath);
+ if (!projectConfig) {
+ return Err(`Project not found: ${found.projectPath}`);
+ }
+
const workspaceEntry =
- projectConfig?.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ??
- projectConfig?.workspaces.find((workspace) => workspace.path === found.workspacePath);
- if (!workspaceEntry?.heartbeat) {
+ projectConfig.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ??
+ projectConfig.workspaces.find((workspace) => workspace.path === found.workspacePath);
+ if (!workspaceEntry) {
+ return Err("Workspace not found");
+ }
+
+ return Ok({ normalizedWorkspaceId, config, workspaceEntry });
+ }
+
+ getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null {
+ const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "getHeartbeatSettings");
+ if (!resolved.success) {
return null;
}
- const message = sanitizeHeartbeatMessage(workspaceEntry.heartbeat.message);
- const contextMode = sanitizeHeartbeatContextMode(workspaceEntry.heartbeat.contextMode);
- return {
- enabled: workspaceEntry.heartbeat.enabled,
- intervalMs: workspaceEntry.heartbeat.intervalMs,
- contextMode,
- ...(message != null ? { message } : {}),
- };
+ const defaultIntervalMs = this.getHeartbeatDefaultIntervalMsFromConfig(resolved.data.config);
+ return normalizeHeartbeatSettings(resolved.data.workspaceEntry.heartbeat, defaultIntervalMs);
+ }
+
+ private getHeartbeatDefaultIntervalMsFromConfig(config: ProjectsConfig): number {
+ const intervalMs = config.heartbeatDefaultIntervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS;
+ assert(
+ Number.isInteger(intervalMs) &&
+ intervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
+ intervalMs <= HEARTBEAT_MAX_INTERVAL_MS,
+ "Configured heartbeat default interval must be within supported bounds"
+ );
+ return intervalMs;
+ }
+
+ getHeartbeatDefaultIntervalMs(): number {
+ const config = this.config.loadConfigOrDefault();
+ return this.getHeartbeatDefaultIntervalMsFromConfig(config);
+ }
+
+ async unsetHeartbeatSettings(workspaceId: string): Promise> {
+ try {
+ const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "unsetHeartbeatSettings");
+ if (!resolved.success) {
+ return Err(resolved.error);
+ }
+
+ const { normalizedWorkspaceId, config, workspaceEntry } = resolved.data;
+ if (!workspaceEntry.heartbeat) {
+ return Ok(undefined);
+ }
+
+ delete workspaceEntry.heartbeat;
+ await this.config.saveConfig(config);
+
+ const interactionTimestamp = Date.now();
+ await this.updateRecencyTimestamp(normalizedWorkspaceId, interactionTimestamp);
+ await this.emitCurrentWorkspaceMetadata(normalizedWorkspaceId);
+
+ return Ok(undefined);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ return Err(`Failed to unset heartbeat settings: ${message}`);
+ }
}
async setHeartbeatSettings(
workspaceId: string,
- settings: WorkspaceHeartbeatSettings
- ): Promise> {
+ settings: WorkspaceHeartbeatSettingsUpdate
+ ): Promise> {
try {
- const normalizedWorkspaceId = workspaceId.trim();
assert(
- normalizedWorkspaceId.length > 0,
- "setHeartbeatSettings requires a non-empty workspaceId"
+ settings != null && typeof settings === "object",
+ "setHeartbeatSettings requires settings"
+ );
+
+ const hasEnabledUpdate = Object.prototype.hasOwnProperty.call(settings, "enabled");
+ assert(
+ !hasEnabledUpdate || typeof settings.enabled === "boolean",
+ "Heartbeat enabled flag must be a boolean when provided"
);
- assert(typeof settings.enabled === "boolean", "Heartbeat enabled flag must be a boolean");
- assert(Number.isInteger(settings.intervalMs), "Heartbeat interval must be an integer");
+ const hasIntervalUpdate = Object.prototype.hasOwnProperty.call(settings, "intervalMs");
assert(
- settings.intervalMs >= HEARTBEAT_MIN_INTERVAL_MS &&
- settings.intervalMs <= HEARTBEAT_MAX_INTERVAL_MS,
+ !hasIntervalUpdate || Number.isInteger(settings.intervalMs),
+ "Heartbeat interval must be an integer when provided"
+ );
+ assert(
+ !hasIntervalUpdate ||
+ (settings.intervalMs! >= HEARTBEAT_MIN_INTERVAL_MS &&
+ settings.intervalMs! <= HEARTBEAT_MAX_INTERVAL_MS),
`Heartbeat interval must be between ${HEARTBEAT_MIN_INTERVAL_MS} and ${HEARTBEAT_MAX_INTERVAL_MS} ms`
);
const hasMessageUpdate = Object.prototype.hasOwnProperty.call(settings, "message");
@@ -4036,36 +4137,29 @@ export class WorkspaceService extends EventEmitter {
"Heartbeat context mode must be a supported value when provided"
);
- const found = this.config.findWorkspace(normalizedWorkspaceId);
- if (!found) {
- return Err("Workspace not found");
- }
-
- const { projectPath, workspacePath } = found;
- const config = this.config.loadConfigOrDefault();
- const projectConfig = config.projects.get(projectPath);
- if (!projectConfig) {
- return Err(`Project not found: ${projectPath}`);
- }
-
- const workspaceEntry =
- projectConfig.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ??
- projectConfig.workspaces.find((workspace) => workspace.path === workspacePath);
- if (!workspaceEntry) {
- return Err("Workspace not found");
+ const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "setHeartbeatSettings");
+ if (!resolved.success) {
+ return Err(resolved.error);
}
+ const { normalizedWorkspaceId, config, workspaceEntry } = resolved.data;
+ const defaultIntervalMs = this.getHeartbeatDefaultIntervalMsFromConfig(config);
+ const currentSettings = normalizeHeartbeatSettings(
+ workspaceEntry.heartbeat,
+ defaultIntervalMs
+ );
const nextMessage = hasMessageUpdate
? sanitizeHeartbeatMessage(settings.message)
- : sanitizeHeartbeatMessage(workspaceEntry.heartbeat?.message);
- const nextContextMode = hasContextModeUpdate
- ? sanitizeHeartbeatContextMode(settings.contextMode)
- : sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode);
+ : currentSettings?.message;
// Keep the interval on disk even when disabled so re-enabling restores the user's choice.
const nextSettings: WorkspaceHeartbeatSettings = {
- enabled: settings.enabled,
- intervalMs: settings.intervalMs,
- contextMode: nextContextMode,
+ enabled: hasEnabledUpdate ? settings.enabled! : (currentSettings?.enabled ?? true),
+ intervalMs: hasIntervalUpdate
+ ? settings.intervalMs!
+ : (currentSettings?.intervalMs ?? defaultIntervalMs),
+ contextMode: hasContextModeUpdate
+ ? sanitizeHeartbeatContextMode(settings.contextMode)
+ : (currentSettings?.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE),
...(nextMessage != null ? { message: nextMessage } : {}),
};
@@ -4073,9 +4167,10 @@ export class WorkspaceService extends EventEmitter {
workspaceEntry.heartbeat?.enabled !== nextSettings.enabled ||
workspaceEntry.heartbeat?.intervalMs !== nextSettings.intervalMs ||
workspaceEntry.heartbeat?.message !== nextSettings.message ||
- sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode) !== nextContextMode;
+ sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode) !==
+ nextSettings.contextMode;
if (!changed) {
- return Ok(undefined);
+ return Ok(nextSettings);
}
workspaceEntry.heartbeat = nextSettings;
@@ -4088,7 +4183,7 @@ export class WorkspaceService extends EventEmitter {
await this.updateRecencyTimestamp(normalizedWorkspaceId, interactionTimestamp);
await this.emitCurrentWorkspaceMetadata(normalizedWorkspaceId);
- return Ok(undefined);
+ return Ok(nextSettings);
} catch (error) {
const message = getErrorMessage(error);
return Err(`Failed to set heartbeat settings: ${message}`);