SaylorTwift HF Staff commited on
Commit
63522a5
·
verified ·
1 Parent(s): a070805

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/api/README.md +98 -0
  2. src/api/agent-canvas-updates.test.ts +41 -0
  3. src/api/agent-canvas-updates.ts +37 -0
  4. src/api/agent-server-adapter.test.ts +704 -0
  5. src/api/agent-server-adapter.ts +1638 -0
  6. src/api/agent-server-client-options.ts +80 -0
  7. src/api/agent-server-compatibility.test.ts +58 -0
  8. src/api/agent-server-compatibility.ts +444 -0
  9. src/api/agent-server-config.test.ts +117 -0
  10. src/api/agent-server-config.ts +290 -0
  11. src/api/agent-server-home.ts +114 -0
  12. src/api/canvas-extensions-service.test.ts +208 -0
  13. src/api/canvas-extensions-service.ts +192 -0
  14. src/api/canvas-ui-client-tool.ts +91 -0
  15. src/api/client-source.ts +20 -0
  16. src/api/conversation-file-upload.api.ts +172 -0
  17. src/api/conversation-metadata-store.ts +128 -0
  18. src/api/device-flow-client.ts +38 -0
  19. src/api/git-provider-items-service.ts +290 -0
  20. src/api/hooks-service.ts +49 -0
  21. src/api/launch-child-conversation-client-tool.ts +112 -0
  22. src/api/llm-balance-service.test.ts +135 -0
  23. src/api/llm-balance-service.ts +113 -0
  24. src/api/llm-subscription-service.ts +202 -0
  25. src/api/main-app-auth.ts +76 -0
  26. src/api/no-direct-agent-server-calls.test.ts +80 -0
  27. src/api/open-hands.types.ts +140 -0
  28. src/api/plugins-management-service.test.ts +137 -0
  29. src/api/plugins-management-service.ts +136 -0
  30. src/api/plugins-service.test.ts +159 -0
  31. src/api/plugins-service.ts +150 -0
  32. src/api/secrets-service.ts +156 -0
  33. src/api/secrets-service.types.ts +15 -0
  34. src/api/skills-service.ts +80 -0
  35. src/api/with-retry.ts +26 -0
  36. src/api/workspace-path-resolution.test.ts +189 -0
  37. src/api/workspace-upload-path.ts +99 -0
  38. src/dev/seed-pending-chat-preview.ts +69 -0
  39. src/fixtures/canvas-demo-conversation.ts +131 -0
  40. src/fixtures/home-automations-demo.ts +478 -0
  41. src/fixtures/table-demo-conversation.ts +58 -0
  42. src/hooks/use-acp-model-context.ts +68 -0
  43. src/hooks/use-agent-notification.ts +49 -0
  44. src/hooks/use-app-title.test.tsx +125 -0
  45. src/hooks/use-app-title.ts +32 -0
  46. src/hooks/use-auto-resize.ts +379 -0
  47. src/hooks/use-automation-permissions.ts +102 -0
  48. src/hooks/use-await-context-compaction.ts +164 -0
  49. src/hooks/use-backend-scoped-path.ts +27 -0
  50. src/hooks/use-bash-command-runner.ts +206 -0
src/api/README.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Services Guide
2
+
3
+ ## Overview
4
+
5
+ Services are the abstraction layer between frontend components and backend APIs. Local agent-server API access should use `@openhands/typescript-client` classes directly, with shared connection options from `src/api/agent-server-client-options.ts` for the active local backend host, session API key, and workspace defaults.
6
+
7
+ Cloud-specific APIs should use the cloud service modules/proxy helpers instead of local agent-server clients.
8
+
9
+ Each service is a plain object with async methods.
10
+
11
+ ## Structure
12
+
13
+ Each service lives in its own directory:
14
+
15
+ ```
16
+ src/api/
17
+ └── feature-service/
18
+ ├── feature-service.api.ts # Service methods
19
+ └── feature.types.ts # Types and interfaces
20
+ ```
21
+
22
+ ## Creating a Service
23
+
24
+ Use an object literal with named export. Use object destructuring for parameters to make calls self-documenting. Prefer typed `@openhands/typescript-client` classes over generic HTTP calls. If a needed endpoint is missing, add it to `@openhands/typescript-client` first.
25
+
26
+ ```typescript
27
+ // feature-service/feature-service.api.ts
28
+ import { FeatureClient } from "@openhands/typescript-client/clients";
29
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
30
+ import { Feature, CreateFeatureParams } from "./feature.types";
31
+
32
+ export const featureService = {
33
+ getFeature: async ({ id }: { id: string }): Promise<Feature> => {
34
+ return new FeatureClient(getAgentServerClientOptions()).getFeature(id);
35
+ },
36
+
37
+ createFeature: async (params: CreateFeatureParams): Promise<Feature> => {
38
+ return new FeatureClient(getAgentServerClientOptions()).createFeature(params);
39
+ },
40
+ };
41
+ ```
42
+
43
+ ### Types
44
+
45
+ Define app-specific types in a separate file within the same directory when the TypeScript client models are not sufficient:
46
+
47
+ ```typescript
48
+ // feature-service/feature.types.ts
49
+ export interface Feature {
50
+ id: string;
51
+ name: string;
52
+ description: string;
53
+ }
54
+
55
+ export interface CreateFeatureParams {
56
+ name: string;
57
+ description: string;
58
+ }
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ > [!IMPORTANT]
64
+ > **Don't call services directly in components.** Wrap them in TanStack Query hooks.
65
+ >
66
+ > Why? TanStack Query provides:
67
+ >
68
+ > - **Caching** - Avoid redundant network requests
69
+ > - **Deduplication** - Multiple components requesting the same data share one request
70
+ > - **Loading/error states** - Built-in `isLoading`, `isError`, `data` states
71
+ > - **Background refetching** - Data stays fresh automatically
72
+ >
73
+ > Hooks location:
74
+ >
75
+ > - `src/hooks/query/` for data fetching (`useQuery`)
76
+ > - `src/hooks/mutation/` for writes/updates (`useMutation`)
77
+
78
+ ```typescript
79
+ // src/hooks/query/use-feature.ts
80
+ import { useQuery } from "@tanstack/react-query";
81
+ import { featureService } from "#/api/feature-service/feature-service.api";
82
+
83
+ export const useFeature = (id: string) => {
84
+ return useQuery({
85
+ queryKey: ["feature", id],
86
+ queryFn: () => featureService.getFeature({ id }),
87
+ });
88
+ };
89
+ ```
90
+
91
+ ## Naming Conventions
92
+
93
+ | Item | Convention | Example |
94
+ | ------------ | ------------------------ | ------------------------ |
95
+ | Directory | `feature-service/` | `secrets-service/` |
96
+ | Service file | `feature-service.api.ts` | `secrets-service.api.ts` |
97
+ | Types file | `feature.types.ts` | `secrets.types.ts` |
98
+ | Export name | `featureService` | `secretsService` |
src/api/agent-canvas-updates.test.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { fetchLatestAgentCanvasVersion } from "./agent-canvas-updates";
3
+
4
+ function stubFetchResponse(response: Partial<Response>) {
5
+ const fetchMock = vi.fn().mockResolvedValue(response as Response);
6
+ vi.stubGlobal("fetch", fetchMock);
7
+ return fetchMock;
8
+ }
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ });
13
+
14
+ describe("fetchLatestAgentCanvasVersion", () => {
15
+ it("fetches the npm registry latest dist-tag and returns the trimmed version", async () => {
16
+ const fetchMock = stubFetchResponse({
17
+ ok: true,
18
+ json: async () => ({ version: " 1.6.0 " }),
19
+ });
20
+
21
+ await expect(fetchLatestAgentCanvasVersion()).resolves.toBe("1.6.0");
22
+ expect(fetchMock).toHaveBeenCalledWith(
23
+ "https://registry.npmjs.org/@openhands/agent-canvas/latest",
24
+ expect.objectContaining({ headers: { Accept: "application/json" } }),
25
+ );
26
+ });
27
+
28
+ it("rejects when the registry responds with an error status", async () => {
29
+ stubFetchResponse({ ok: false, status: 503 });
30
+
31
+ await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow("503");
32
+ });
33
+
34
+ it("rejects when the response has no usable version field", async () => {
35
+ stubFetchResponse({ ok: true, json: async () => ({ version: 42 }) });
36
+
37
+ await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow(
38
+ "missing version",
39
+ );
40
+ });
41
+ });
src/api/agent-canvas-updates.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Source of truth for "is a newer Agent Canvas published?".
3
+ *
4
+ * The npm registry `latest` dist-tag endpoint is CORS-open and returns the
5
+ * abbreviated packument for that version as JSON. Isolated here so the
6
+ * release channel can change (e.g. GitHub releases) without touching the
7
+ * query hook or the settings update card.
8
+ */
9
+ const NPM_LATEST_VERSION_URL =
10
+ "https://registry.npmjs.org/@openhands/agent-canvas/latest";
11
+
12
+ export const AGENT_CANVAS_RELEASE_NOTES_URL =
13
+ "https://github.com/OpenHands/OpenHands/releases";
14
+
15
+ /** Literal shell commands — intentionally not localized. */
16
+ export const AGENT_CANVAS_UPDATE_COMMANDS = {
17
+ npm: "npm install -g @openhands/agent-canvas@latest",
18
+ docker: "docker pull ghcr.io/openhands/agent-canvas:latest",
19
+ } as const;
20
+
21
+ export async function fetchLatestAgentCanvasVersion(
22
+ signal?: AbortSignal,
23
+ ): Promise<string> {
24
+ const response = await fetch(NPM_LATEST_VERSION_URL, {
25
+ signal,
26
+ headers: { Accept: "application/json" },
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`npm registry responded ${response.status}`);
30
+ }
31
+ const body: unknown = await response.json();
32
+ const version = (body as { version?: unknown } | null)?.version;
33
+ if (typeof version !== "string" || !version.trim()) {
34
+ throw new Error("npm registry response missing version");
35
+ }
36
+ return version.trim();
37
+ }
src/api/agent-server-adapter.test.ts ADDED
@@ -0,0 +1,704 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
3
+ import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
4
+ import { DEFAULT_SETTINGS } from "#/services/settings";
5
+ import type { Settings } from "#/types/settings";
6
+ import {
7
+ AGENT_CANVAS_SOURCE,
8
+ CLIENT_SOURCE_TAG_KEY,
9
+ buildStartConversationRequest,
10
+ buildStartPlanningConversationRequest,
11
+ buildStartPlanningConversationRequestWithEncryptedSettings,
12
+ toConversationPage,
13
+ } from "./agent-server-adapter";
14
+ import SettingsService from "./settings-service/settings-service.api";
15
+ import AgentProfilesService from "./agent-profiles-service/agent-profiles-service.api";
16
+ import ProfilesService from "./profiles-service/profiles-service.api";
17
+
18
+ vi.mock("./settings-service/settings-service.api", () => ({
19
+ default: { getSettingsForConversation: vi.fn() },
20
+ }));
21
+ vi.mock("./secrets-service", () => ({
22
+ SecretsService: { getSecrets: vi.fn().mockResolvedValue([]) },
23
+ }));
24
+ vi.mock("./agent-profiles-service/agent-profiles-service.api", () => ({
25
+ default: { listProfiles: vi.fn() },
26
+ }));
27
+ vi.mock("./profiles-service/profiles-service.api", () => ({
28
+ default: { getProfile: vi.fn() },
29
+ }));
30
+
31
+ const encryptedValue = "gAAAAAencrypted-mcp-header";
32
+
33
+ function makeSettings(agentSettings: Settings["agent_settings"]): Settings {
34
+ return {
35
+ ...DEFAULT_SETTINGS,
36
+ agent_settings: agentSettings,
37
+ conversation_settings: {
38
+ confirmation_mode: false,
39
+ security_analyzer: null,
40
+ max_iterations: 20,
41
+ },
42
+ };
43
+ }
44
+
45
+ function getAgentContextSkillNames(
46
+ payload: ReturnType<typeof buildStartConversationRequest>,
47
+ ): Array<string | undefined> {
48
+ const agentSettings = payload.agent_settings as
49
+ | {
50
+ agent_context?: {
51
+ skills?: Array<{ name?: string }>;
52
+ };
53
+ }
54
+ | undefined;
55
+ return agentSettings?.agent_context?.skills?.map((skill) => skill.name) ?? [];
56
+ }
57
+
58
+ function getPlannerAgentContextSkillNames(payload: {
59
+ agent: { agent_context?: { skills?: Array<{ name?: string }> } };
60
+ }): Array<string | undefined> {
61
+ return payload.agent.agent_context?.skills?.map((skill) => skill.name) ?? [];
62
+ }
63
+
64
+ describe("buildStartConversationRequest", () => {
65
+ it("marks OpenHands start requests as encrypted when MCP headers are encrypted", () => {
66
+ const agentSettings = {
67
+ agent_kind: "openhands",
68
+ llm: {
69
+ model: "litellm_proxy/openai/gpt-5.5",
70
+ api_key: "gAAAAAencrypted-llm-api-key",
71
+ },
72
+ mcp_config: {
73
+ linear: {
74
+ url: "https://mcp.linear.app/mcp",
75
+ transport: "http",
76
+ headers: {
77
+ Authorization: encryptedValue,
78
+ },
79
+ },
80
+ },
81
+ };
82
+ const settings = makeSettings(agentSettings);
83
+
84
+ const payload = buildStartConversationRequest({
85
+ settings,
86
+ encryptedAgentSettings: agentSettings,
87
+ encryptedConversationSettings: settings.conversation_settings!,
88
+ secretsEncrypted: true,
89
+ });
90
+
91
+ expect(payload.agent_settings!.agent_kind).toBe("openhands");
92
+ expect(payload.agent_settings!.mcp_config).toEqual(
93
+ agentSettings.mcp_config,
94
+ );
95
+ expect(payload.secrets_encrypted).toBe(true);
96
+ });
97
+
98
+ it("marks ACP start requests as encrypted when MCP headers are encrypted", () => {
99
+ const agentSettings = {
100
+ agent_kind: "acp",
101
+ acp_server: "codex",
102
+ acp_command: ["codex-acp"],
103
+ acp_model: "gpt-5.5/medium",
104
+ mcp_config: {
105
+ linear: {
106
+ url: "https://mcp.linear.app/mcp",
107
+ transport: "http",
108
+ headers: {
109
+ Authorization: encryptedValue,
110
+ },
111
+ },
112
+ },
113
+ };
114
+ const settings = makeSettings(agentSettings);
115
+
116
+ const payload = buildStartConversationRequest({
117
+ settings,
118
+ encryptedAgentSettings: agentSettings,
119
+ encryptedConversationSettings: settings.conversation_settings!,
120
+ secretsEncrypted: true,
121
+ });
122
+
123
+ expect(payload.agent_settings!.agent_kind).toBe("acp");
124
+ expect(payload.agent_settings!.mcp_config).toEqual(
125
+ agentSettings.mcp_config,
126
+ );
127
+ expect(payload.secrets_encrypted).toBe(true);
128
+ });
129
+
130
+ it("builds a raw planning agent request for local Planner", () => {
131
+ const agentSettings = {
132
+ agent_kind: "openhands",
133
+ llm: {
134
+ model: "openhands/minimax-m2.7",
135
+ api_key: "gAAAAAencrypted-llm-api-key",
136
+ },
137
+ };
138
+
139
+ const payload = buildStartPlanningConversationRequest({
140
+ encryptedAgentSettings: agentSettings,
141
+ workingDir: "/workspace/project/agent-canvas",
142
+ parentConversationId: "parent-1",
143
+ secretsEncrypted: true,
144
+ customSecrets: [{ name: "CUSTOM_TOKEN" }],
145
+ });
146
+
147
+ expect(payload.agent).toMatchObject({
148
+ kind: "Agent",
149
+ system_prompt_filename: "system_prompt_planning.j2",
150
+ system_prompt_kwargs: {
151
+ plan_structure: expect.stringContaining("OBJECTIVE"),
152
+ },
153
+ llm: {
154
+ model: "openhands/minimax-m2.7",
155
+ api_key: "gAAAAAencrypted-llm-api-key",
156
+ },
157
+ tools: [
158
+ { name: "glob", params: {} },
159
+ { name: "grep", params: {} },
160
+ {
161
+ name: "planning_file_editor",
162
+ params: {
163
+ plan_path: "/workspace/project/agent-canvas/.agents_tmp/PLAN.md",
164
+ },
165
+ },
166
+ ],
167
+ // Matches the SDK planning preset's get_planning_condenser.
168
+ condenser: {
169
+ kind: "LLMSummarizingCondenser",
170
+ max_size: 100,
171
+ keep_first: 6,
172
+ llm: {
173
+ model: "openhands/minimax-m2.7",
174
+ usage_id: "planning_condenser",
175
+ },
176
+ },
177
+ });
178
+ expect(payload.agent_settings).toBeUndefined();
179
+ expect(payload.worktree).toBe(false);
180
+ expect(payload.tags).toEqual({ plannerparent: "parent-1" });
181
+ expect(payload.secrets_encrypted).toBe(true);
182
+ expect(payload.secrets).toHaveProperty("CUSTOM_TOKEN");
183
+ });
184
+
185
+ it("links the local planner to its parent server-side so the relationship survives storage loss", () => {
186
+ const payload = buildStartPlanningConversationRequest({
187
+ encryptedAgentSettings: {
188
+ agent_kind: "openhands",
189
+ llm: { model: "openhands/minimax-m2.7" },
190
+ },
191
+ workingDir: "/workspace/project",
192
+ parentConversationId: "parent-1",
193
+ });
194
+
195
+ // The agent-server derives the parent's `sub_conversation_ids` from this,
196
+ // which is what makes the planner recoverable without localStorage.
197
+ expect(payload.parent_conversation_id).toBe("parent-1");
198
+ });
199
+
200
+ it("defaults the planner's max_iterations to 500 when not provided", () => {
201
+ const payload = buildStartPlanningConversationRequest({
202
+ encryptedAgentSettings: {
203
+ agent_kind: "openhands",
204
+ llm: { model: "openhands/minimax-m2.7" },
205
+ },
206
+ workingDir: "/workspace/project",
207
+ parentConversationId: "parent-1",
208
+ });
209
+
210
+ expect(payload.max_iterations).toBe(500);
211
+ });
212
+
213
+ it("uses the caller-provided max_iterations instead of the hardcoded default", () => {
214
+ const payload = buildStartPlanningConversationRequest({
215
+ encryptedAgentSettings: {
216
+ agent_kind: "openhands",
217
+ llm: { model: "openhands/minimax-m2.7" },
218
+ },
219
+ workingDir: "/workspace/project",
220
+ parentConversationId: "parent-1",
221
+ maxIterations: 250,
222
+ });
223
+
224
+ expect(payload.max_iterations).toBe(250);
225
+ });
226
+
227
+ it("omits local planner helper conversations from paginated conversation results", () => {
228
+ const page = toConversationPage({
229
+ items: [
230
+ {
231
+ id: "main-1",
232
+ created_at: "2024-01-01T00:00:00.000Z",
233
+ updated_at: "2024-01-01T00:00:00.000Z",
234
+ execution_status: "idle",
235
+ tags: {},
236
+ },
237
+ {
238
+ id: "plan-1",
239
+ created_at: "2024-01-01T00:00:00.000Z",
240
+ updated_at: "2024-01-01T00:00:00.000Z",
241
+ execution_status: "idle",
242
+ tags: { plannerparent: "main-1" },
243
+ },
244
+ ],
245
+ });
246
+
247
+ expect(page.items.map((item) => item.id)).toEqual(["main-1"]);
248
+ });
249
+
250
+ it("keeps ACP start requests unencrypted when no encrypted MCP values are present", () => {
251
+ const agentSettings = {
252
+ agent_kind: "acp",
253
+ acp_server: "codex",
254
+ acp_command: ["codex-acp"],
255
+ acp_model: "gpt-5.5/medium",
256
+ mcp_config: {
257
+ publicDocs: {
258
+ url: "https://docs.example.com/mcp",
259
+ transport: "http",
260
+ },
261
+ },
262
+ };
263
+ const settings = makeSettings(agentSettings);
264
+
265
+ const payload = buildStartConversationRequest({
266
+ settings,
267
+ encryptedAgentSettings: agentSettings,
268
+ encryptedConversationSettings: settings.conversation_settings!,
269
+ secretsEncrypted: true,
270
+ });
271
+
272
+ expect(payload.agent_settings!.agent_kind).toBe("acp");
273
+ expect(payload.secrets_encrypted).toBeUndefined();
274
+ });
275
+
276
+ it("ships only allow-listed catalog skills to a OpenHands conversation context", () => {
277
+ const settings = makeSettings({
278
+ agent_kind: "openhands",
279
+ llm: {
280
+ model: "litellm_proxy/openai/gpt-5.5",
281
+ api_key: "sk-test",
282
+ },
283
+ agent_context: {
284
+ skills: [
285
+ { name: "disabled-custom", content: "disabled" },
286
+ { name: "enabled-custom", content: "enabled" },
287
+ ],
288
+ },
289
+ });
290
+ settings.disabled_skills = ["agent-memory", "disabled-custom"];
291
+
292
+ const payload = buildStartConversationRequest({ settings });
293
+ const skillNames = getAgentContextSkillNames(payload);
294
+
295
+ // `agent-memory` is default-enabled, so this asserts the deny-list still
296
+ // wins over the allow-list — the case that matters before the one-shot
297
+ // migration has run.
298
+ expect(skillNames).not.toContain("agent-memory");
299
+ expect(skillNames).not.toContain("disabled-custom");
300
+ // Skills already on the agent context are user-authored and stay opt-out.
301
+ expect(skillNames).toContain("enabled-custom");
302
+ // No `enabled_skills` on the settings means the curated default applies:
303
+ // `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is
304
+ // not, and shipping every catalog skill is what OpenHands#16302 reported.
305
+ expect(skillNames).toContain("add-skill");
306
+ expect(skillNames).not.toContain("add-javadoc");
307
+
308
+ expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([
309
+ "agent-memory",
310
+ "disabled-custom",
311
+ ]);
312
+ });
313
+
314
+ it("ships only allow-listed catalog skills to a ACP conversation context", () => {
315
+ const settings = makeSettings({
316
+ agent_kind: "acp",
317
+ acp_server: "codex",
318
+ acp_command: ["codex-acp"],
319
+ acp_model: "gpt-5.5/medium",
320
+ agent_context: {
321
+ skills: [
322
+ { name: "disabled-custom", content: "disabled" },
323
+ { name: "enabled-custom", content: "enabled" },
324
+ ],
325
+ },
326
+ });
327
+ settings.disabled_skills = ["agent-memory", "disabled-custom"];
328
+
329
+ const payload = buildStartConversationRequest({ settings });
330
+ const skillNames = getAgentContextSkillNames(payload);
331
+
332
+ // `agent-memory` is default-enabled, so this asserts the deny-list still
333
+ // wins over the allow-list — the case that matters before the one-shot
334
+ // migration has run.
335
+ expect(skillNames).not.toContain("agent-memory");
336
+ expect(skillNames).not.toContain("disabled-custom");
337
+ // Skills already on the agent context are user-authored and stay opt-out.
338
+ expect(skillNames).toContain("enabled-custom");
339
+ // No `enabled_skills` on the settings means the curated default applies:
340
+ // `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is
341
+ // not, and shipping every catalog skill is what OpenHands#16302 reported.
342
+ expect(skillNames).toContain("add-skill");
343
+ expect(skillNames).not.toContain("add-javadoc");
344
+
345
+ expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([
346
+ "agent-memory",
347
+ "disabled-custom",
348
+ ]);
349
+ });
350
+
351
+ it("loads a catalog skill the opening slash command invokes", () => {
352
+ // An automation card fills the chat input with the skill's own command
353
+ // (`findAutomationCommand`), and 18 of the catalog's 24 slash commands
354
+ // belong to skills that are off by default — without this the card would
355
+ // silently do nothing.
356
+ const settings = makeSettings({
357
+ agent_kind: "openhands",
358
+ llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
359
+ });
360
+
361
+ const payload = buildStartConversationRequest({
362
+ settings,
363
+ query: "/standup-digest:setup",
364
+ });
365
+
366
+ expect(getAgentContextSkillNames(payload)).toContain(
367
+ "slack-standup-digest",
368
+ );
369
+ });
370
+
371
+ it("does not admit a skill named mid-sentence rather than invoked", () => {
372
+ const settings = makeSettings({
373
+ agent_kind: "openhands",
374
+ llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
375
+ });
376
+
377
+ const payload = buildStartConversationRequest({
378
+ settings,
379
+ query: "tell me what /standup-digest:setup would do",
380
+ });
381
+
382
+ expect(getAgentContextSkillNames(payload)).not.toContain(
383
+ "slack-standup-digest",
384
+ );
385
+ });
386
+
387
+ it("lets an invoked skill override a stored deny entry for that conversation", () => {
388
+ const settings = makeSettings({
389
+ agent_kind: "openhands",
390
+ llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
391
+ });
392
+ settings.disabled_skills = ["slack-standup-digest"];
393
+
394
+ const payload = buildStartConversationRequest({
395
+ settings,
396
+ query: "/standup-digest:setup",
397
+ });
398
+
399
+ expect(getAgentContextSkillNames(payload)).toContain(
400
+ "slack-standup-digest",
401
+ );
402
+ });
403
+ });
404
+
405
+ describe("buildStartPlanningConversationRequestWithEncryptedSettings", () => {
406
+ const globallyActiveSettings = {
407
+ agentSettings: {
408
+ agent_kind: "openhands",
409
+ llm: {
410
+ model: "openhands/globally-active-model",
411
+ api_key: "gAAAAAglobal-key",
412
+ base_url: "https://global.example.com",
413
+ },
414
+ },
415
+ conversationSettings: {},
416
+ secretsEncrypted: true,
417
+ skillEnablement: { disabledSkills: [] },
418
+ };
419
+
420
+ beforeEach(() => {
421
+ vi.clearAllMocks();
422
+ vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue(
423
+ globallyActiveSettings,
424
+ );
425
+ vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({
426
+ profiles: [
427
+ {
428
+ id: "profile-parent",
429
+ name: "parent-profile",
430
+ agent_kind: "openhands",
431
+ revision: 1,
432
+ llm_profile_ref: "parent-llm",
433
+ mcp_server_refs: null,
434
+ },
435
+ ],
436
+ active_agent_profile_id: "profile-other",
437
+ });
438
+ vi.mocked(ProfilesService.getProfile).mockResolvedValue({
439
+ name: "parent-llm",
440
+ api_key_set: true,
441
+ config: {
442
+ model: "openhands/parent-profile-model",
443
+ api_key: "gAAAAAparent-key",
444
+ base_url: "https://parent.example.com",
445
+ },
446
+ });
447
+ });
448
+
449
+ it("pins the planner to the parent's launched profile, not the globally active one", async () => {
450
+ const payload =
451
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
452
+ workingDir: "/workspace/project",
453
+ parentConversationId: "parent-1",
454
+ parentAgentProfileId: "profile-parent",
455
+ });
456
+
457
+ expect(ProfilesService.getProfile).toHaveBeenCalledWith(
458
+ "parent-llm",
459
+ "encrypted",
460
+ );
461
+ expect(payload.agent.llm).toMatchObject({
462
+ model: "openhands/parent-profile-model",
463
+ api_key: "gAAAAAparent-key",
464
+ base_url: "https://parent.example.com",
465
+ });
466
+ });
467
+
468
+ it("falls back to global settings when the parent was not launched from a profile", async () => {
469
+ const payload =
470
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
471
+ workingDir: "/workspace/project",
472
+ parentConversationId: "parent-1",
473
+ parentAgentProfileId: null,
474
+ });
475
+
476
+ expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled();
477
+ expect(payload.agent.llm).toMatchObject({
478
+ model: "openhands/globally-active-model",
479
+ });
480
+ });
481
+
482
+ it("prefers the parent conversation's current active_profile over its launched agent profile", async () => {
483
+ // The conversation launched under "parent-profile" (-> "parent-llm"), but
484
+ // was later switched to a different LLM profile via /model or the
485
+ // agent's own SwitchLLMTool — active_profile tracks that switch.
486
+ vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) =>
487
+ name === "switched-llm"
488
+ ? {
489
+ name: "switched-llm",
490
+ api_key_set: true,
491
+ config: {
492
+ model: "openhands/switched-model",
493
+ api_key: "gAAAAAswitched-key",
494
+ base_url: "https://switched.example.com",
495
+ },
496
+ }
497
+ : {
498
+ name: "parent-llm",
499
+ api_key_set: true,
500
+ config: {
501
+ model: "openhands/parent-profile-model",
502
+ api_key: "gAAAAAparent-key",
503
+ base_url: "https://parent.example.com",
504
+ },
505
+ },
506
+ );
507
+
508
+ const payload =
509
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
510
+ workingDir: "/workspace/project",
511
+ parentConversationId: "parent-1",
512
+ parentActiveProfileName: "switched-llm",
513
+ parentAgentProfileId: "profile-parent",
514
+ });
515
+
516
+ expect(ProfilesService.getProfile).toHaveBeenCalledWith(
517
+ "switched-llm",
518
+ "encrypted",
519
+ );
520
+ // The launched agent profile must not even be consulted once
521
+ // active_profile resolves successfully.
522
+ expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled();
523
+ expect(payload.agent.llm).toMatchObject({
524
+ model: "openhands/switched-model",
525
+ api_key: "gAAAAAswitched-key",
526
+ base_url: "https://switched.example.com",
527
+ });
528
+ });
529
+
530
+ it("falls back to the launched agent profile when active_profile can't be resolved", async () => {
531
+ vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => {
532
+ if (name === "dangling-llm") throw new Error("not found");
533
+ return {
534
+ name: "parent-llm",
535
+ api_key_set: true,
536
+ config: {
537
+ model: "openhands/parent-profile-model",
538
+ api_key: "gAAAAAparent-key",
539
+ base_url: "https://parent.example.com",
540
+ },
541
+ };
542
+ });
543
+
544
+ const payload =
545
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
546
+ workingDir: "/workspace/project",
547
+ parentConversationId: "parent-1",
548
+ parentActiveProfileName: "dangling-llm",
549
+ parentAgentProfileId: "profile-parent",
550
+ });
551
+
552
+ expect(payload.agent.llm).toMatchObject({
553
+ model: "openhands/parent-profile-model",
554
+ });
555
+ });
556
+
557
+ it("falls back to global settings when the parent's profile reference dangles", async () => {
558
+ vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({
559
+ profiles: [],
560
+ active_agent_profile_id: null,
561
+ });
562
+
563
+ const payload =
564
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
565
+ workingDir: "/workspace/project",
566
+ parentConversationId: "parent-1",
567
+ parentAgentProfileId: "profile-parent",
568
+ });
569
+
570
+ expect(ProfilesService.getProfile).not.toHaveBeenCalled();
571
+ expect(payload.agent.llm).toMatchObject({
572
+ model: "openhands/globally-active-model",
573
+ });
574
+ });
575
+
576
+ it("mirrors the parent's configured max_iterations instead of hardcoding a lower cap", async () => {
577
+ vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({
578
+ ...globallyActiveSettings,
579
+ conversationSettings: { max_iterations: 1000 },
580
+ });
581
+
582
+ const payload =
583
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
584
+ workingDir: "/workspace/project",
585
+ parentConversationId: "parent-1",
586
+ });
587
+
588
+ expect(payload.max_iterations).toBe(1000);
589
+ });
590
+
591
+ it("falls back to 500 when the parent has no configured max_iterations", async () => {
592
+ const payload =
593
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
594
+ workingDir: "/workspace/project",
595
+ parentConversationId: "parent-1",
596
+ });
597
+
598
+ expect(payload.max_iterations).toBe(500);
599
+ });
600
+
601
+ it("applies the full skill enablement to the planner's agent context, mirroring the code agent", async () => {
602
+ // Both lists, because they cover different populations (#16302): the
603
+ // allow-list gates the bundled catalog, the deny-list still wins over it.
604
+ // `add-javadoc` is a catalog skill outside the recommended defaults, so
605
+ // it only loads because the allow-list names it.
606
+ vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({
607
+ ...globallyActiveSettings,
608
+ skillEnablement: {
609
+ enabledSkills: ["add-javadoc", "agent-memory"],
610
+ disabledSkills: ["agent-memory"],
611
+ },
612
+ });
613
+
614
+ const payload =
615
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
616
+ workingDir: "/workspace/project",
617
+ parentConversationId: "parent-1",
618
+ });
619
+
620
+ const skillNames = getPlannerAgentContextSkillNames(payload);
621
+ expect(skillNames).not.toContain("agent-memory");
622
+ expect(skillNames).toContain("add-javadoc");
623
+ });
624
+ });
625
+
626
+ describe("buildStartConversationRequest — agentProfileId path", () => {
627
+ it("sends agent_profile_id and omits agent_settings (mutually exclusive)", () => {
628
+ const settings = makeSettings({
629
+ agent_kind: "openhands",
630
+ llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
631
+ });
632
+
633
+ const payload = buildStartConversationRequest({
634
+ settings,
635
+ agentProfileId: "profile-xyz",
636
+ agentProfileKind: "openhands",
637
+ });
638
+
639
+ expect(payload.agent_profile_id).toBe("profile-xyz");
640
+ expect(payload.agent_settings).toBeUndefined();
641
+ expect(payload.client_tools.map((tool) => tool.name)).toEqual([
642
+ CANVAS_UI_CLIENT_TOOL_NAME,
643
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
644
+ ]);
645
+ });
646
+
647
+ it("suppresses the ACP server tag when launching from a profile", () => {
648
+ const agentSettings = {
649
+ agent_kind: "acp",
650
+ acp_server: "codex",
651
+ acp_command: ["codex-acp"],
652
+ acp_model: "gpt-5.5/medium",
653
+ };
654
+
655
+ // Without a profile the ACP server tag is stamped from settings...
656
+ expect(
657
+ buildStartConversationRequest({ settings: makeSettings(agentSettings) })
658
+ .tags,
659
+ ).toBeDefined();
660
+
661
+ // ...but a profile launch resolves the server server-side, so the tag
662
+ // (which may not match the launched profile) is omitted while the client
663
+ // source telemetry tag is still stamped.
664
+ const payload = buildStartConversationRequest({
665
+ settings: makeSettings(agentSettings),
666
+ agentProfileId: "profile-xyz",
667
+ });
668
+ expect(payload.tags).toEqual({
669
+ [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
670
+ });
671
+ });
672
+
673
+ it("suppresses secrets_encrypted when launching from a profile", () => {
674
+ const agentSettings = {
675
+ agent_kind: "openhands",
676
+ llm: {
677
+ model: "litellm_proxy/openai/gpt-5.5",
678
+ api_key: "gAAAAAencrypted-llm-api-key",
679
+ },
680
+ mcp_config: {
681
+ mcpServers: {
682
+ linear: {
683
+ url: "https://mcp.linear.app/mcp",
684
+ transport: "http",
685
+ headers: { Authorization: encryptedValue },
686
+ },
687
+ },
688
+ },
689
+ };
690
+ const settings = makeSettings(agentSettings);
691
+
692
+ // Same inputs without a profile would set secrets_encrypted (covered
693
+ // above); the profile path defers secret resolution to the server.
694
+ const payload = buildStartConversationRequest({
695
+ settings,
696
+ encryptedAgentSettings: agentSettings,
697
+ encryptedConversationSettings: settings.conversation_settings!,
698
+ secretsEncrypted: true,
699
+ agentProfileId: "profile-xyz",
700
+ });
701
+
702
+ expect(payload.secrets_encrypted).toBeUndefined();
703
+ });
704
+ });
src/api/agent-server-adapter.ts ADDED
@@ -0,0 +1,1638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client";
2
+ import type { HookConfig } from "@openhands/typescript-client";
3
+ import { ServerClient } from "@openhands/typescript-client/clients";
4
+ import { SKILLS_CATALOG } from "@openhands/extensions/skills";
5
+ import { DEFAULT_SETTINGS } from "#/services/settings";
6
+ import { ExecutionStatus } from "#/types/agent-server/core";
7
+ import { AgentKind, Settings, SettingsValue } from "#/types/settings";
8
+ import {
9
+ getAcpPreferredDefaultModel,
10
+ getAcpProvider,
11
+ resolveEffectiveAcpModel,
12
+ } from "#/constants/acp-providers";
13
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
14
+ import {
15
+ getCachedAgentServerInfo,
16
+ isAgentServerToolAvailable,
17
+ } from "./agent-server-compatibility";
18
+ import { getAgentServerWorkingDir } from "./agent-server-config";
19
+ import { getEffectiveLocalBackend } from "./backend-registry/active-store";
20
+ import { buildAuthHeaders } from "./backend-registry/auth";
21
+ import {
22
+ GetHooksResponse,
23
+ PluginSpec,
24
+ AppConversation,
25
+ AppConversationPage,
26
+ RuntimeConversationStats,
27
+ SandboxStatus,
28
+ } from "./conversation-service/agent-server-conversation-service.types";
29
+ import { combineUsageMetrics } from "#/utils/conversation-metrics";
30
+ import {
31
+ buildSkillEnablementFilter,
32
+ findInvokedCatalogSkill,
33
+ toSkillEnablement,
34
+ type SkillEnablement,
35
+ } from "#/utils/skill-enablement";
36
+ import SettingsService from "./settings-service/settings-service.api";
37
+ import { getStoredConversationMetadata } from "./conversation-metadata-store";
38
+ import LLMSubscriptionService from "./llm-subscription-service";
39
+ import {
40
+ LLM_AUTH_TYPE_SUBSCRIPTION,
41
+ OPENAI_SUBSCRIPTION_VENDOR,
42
+ isSubscriptionLlmConfig,
43
+ } from "#/constants/llm-subscription";
44
+ import {
45
+ CANVAS_UI_CLIENT_TOOL,
46
+ CANVAS_UI_CLIENT_TOOL_NAME,
47
+ LEGACY_CANVAS_UI_TOOL_NAME,
48
+ type ClientToolSpec,
49
+ } from "./canvas-ui-client-tool";
50
+ import {
51
+ LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL,
52
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
53
+ } from "./launch-child-conversation-client-tool";
54
+ import {
55
+ buildPlanPath,
56
+ LOCAL_PLANNER_PARENT_TAG_KEY,
57
+ PLAN_STRUCTURE_TEXT,
58
+ PLANNING_AGENT_INSTRUCTION,
59
+ PLANNING_FILE_EDITOR_TOOL_NAME,
60
+ PLANNING_SYSTEM_PROMPT_FILENAME,
61
+ } from "#/utils/plan-file";
62
+
63
+ export interface DirectConversationInfo {
64
+ id: string;
65
+ title?: string | null;
66
+ created_at: string;
67
+ updated_at: string;
68
+ execution_status?: string | null;
69
+ /** Cloud-only sandbox lifecycle state. Omitted / null for local agent-server conversations. */
70
+ sandbox_status?: string | null;
71
+ metrics?: {
72
+ accumulated_cost?: number | null;
73
+ max_budget_per_task?: number | null;
74
+ accumulated_token_usage?: {
75
+ prompt_tokens?: number;
76
+ completion_tokens?: number;
77
+ cache_read_tokens?: number;
78
+ cache_write_tokens?: number;
79
+ context_window?: number;
80
+ per_turn_token?: number;
81
+ } | null;
82
+ } | null;
83
+ /**
84
+ * Raw per-usage-id LLM stats from the agent-server. Search/list responses
85
+ * often carry real usage here even when `metrics` above comes back unset;
86
+ * {@link toAppConversation} combines this as a fallback in that case.
87
+ */
88
+ stats?: RuntimeConversationStats | null;
89
+ agent?: {
90
+ /**
91
+ * Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI
92
+ * subprocesses (model lives on the subprocess via ``acp_model``),
93
+ * ``"Agent"`` for direct litellm. Read by {@link toAppConversation}.
94
+ */
95
+ kind?: string | null;
96
+ acp_model?: string | null;
97
+ /**
98
+ * ACP CLI identity (``claude-code`` / ``codex`` / ``gemini-cli``) from the
99
+ * SDK's ``ACPAgent.acp_server`` (#3692). Preferred fallback when the
100
+ * ``acpserver`` tag is absent — e.g. a profile launch doesn't stamp the tag
101
+ * client-side and the server may not repopulate it. Read by {@link toAppConversation}.
102
+ */
103
+ acp_server?: string | null;
104
+ llm?: {
105
+ model?: string | null;
106
+ } | null;
107
+ } | null;
108
+ current_model_id?: string | null;
109
+ current_model_name?: string | null;
110
+ workspace?: {
111
+ working_dir?: string | null;
112
+ } | null;
113
+ /**
114
+ * Arbitrary string-keyed conversation tags surfaced by the agent-server
115
+ * (see ``ConversationInfo.tags``). Canvas only consumes one key today —
116
+ * ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a
117
+ * generic record so future readers don't need another wire-shape change.
118
+ * Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator;
119
+ * values are opaque strings.
120
+ */
121
+ tags?: Record<string, string> | null;
122
+ launched_agent_profile?: {
123
+ agent_profile_id: string;
124
+ revision: number;
125
+ } | null;
126
+ /**
127
+ * Server-owned, derived from the catalog's ``parent_conversation_id`` link
128
+ * (agent-server >= 1.37.1, SDK #4188) — a conversation started with a
129
+ * parent is discoverable here on any browser, which is what makes the
130
+ * local planner's relationship server state rather than a browser-local
131
+ * hint. Absent on older agent-servers and on the cloud wire shape.
132
+ */
133
+ sub_conversation_ids?: string[] | null;
134
+ }
135
+
136
+ const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"];
137
+ const BROWSER_TOOL_SET_NAME = "browser_tool_set";
138
+ const TASK_TOOL_SET_NAME = "task_tool_set";
139
+ // Falls back to the same default the code agent uses when the user has not
140
+ // configured `conversation_settings.max_iterations` (see buildConfiguredConversationSettings).
141
+ const DEFAULT_MAX_ITERATIONS = 500;
142
+
143
+ function resolveMaxIterations(value: unknown): number {
144
+ return typeof value === "number" ? value : DEFAULT_MAX_ITERATIONS;
145
+ }
146
+
147
+ function browserToolsEnabled() {
148
+ return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false";
149
+ }
150
+
151
+ /**
152
+ * Shape of the runtime services info served by Agent Canvas backends in
153
+ * `/server_info.runtime_services`. All URLs are written from the agent's point of
154
+ * view, not the browser's. The block is rendered into the agent's system prompt
155
+ * via `AgentContext.system_message_suffix` so the agent knows what's reachable
156
+ * from inside its sandbox without having to probe.
157
+ */
158
+ export interface RuntimeServicesInfo {
159
+ mode?: string;
160
+ agent_host_alias?: string;
161
+ services?: {
162
+ agent_server?: { description?: string; url_from_agent?: string };
163
+ ingress?: { description?: string; url_from_agent?: string };
164
+ frontend?: {
165
+ kind?: "vite" | "static";
166
+ description?: string;
167
+ url_from_agent?: string;
168
+ };
169
+ // `vite` is the legacy key name for the frontend entry, accepted for
170
+ // one release while older dev-stack launchers may still emit it.
171
+ vite?: { description?: string; url_from_agent?: string };
172
+ automation?: {
173
+ description?: string;
174
+ url_from_agent?: string;
175
+ api_prefix?: string;
176
+ docs_url?: string;
177
+ openapi_url?: string;
178
+ auth_env_var?: string;
179
+ };
180
+ };
181
+ }
182
+
183
+ export function parseRuntimeServicesInfo(
184
+ value: unknown,
185
+ ): RuntimeServicesInfo | null {
186
+ if (typeof value === "string") {
187
+ const raw = value.trim();
188
+ if (!raw) return null;
189
+ try {
190
+ return parseRuntimeServicesInfo(JSON.parse(raw));
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
197
+ return null;
198
+ }
199
+
200
+ const parsed = value as RuntimeServicesInfo;
201
+ if (!parsed.services || typeof parsed.services !== "object") return null;
202
+ return parsed;
203
+ }
204
+
205
+ export async function fetchBackendRuntimeServicesInfo(): Promise<RuntimeServicesInfo | null> {
206
+ let clientOptions: ReturnType<typeof getAgentServerClientOptions>;
207
+ try {
208
+ clientOptions = getAgentServerClientOptions({ timeout: 3000 });
209
+ } catch {
210
+ return null;
211
+ }
212
+
213
+ const cached = parseRuntimeServicesInfo(
214
+ getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services,
215
+ );
216
+ if (cached) return cached;
217
+
218
+ try {
219
+ const serverInfo = await new ServerClient(clientOptions).getServerInfo();
220
+ return parseRuntimeServicesInfo(
221
+ (serverInfo as { runtime_services?: unknown }).runtime_services,
222
+ );
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Return the deployment mode from the runtime services info, e.g. "docker",
230
+ * "dev:automation", etc. Returns `null` when no runtime info is supplied.
231
+ */
232
+ export function getDeploymentMode(
233
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
234
+ ): string | null {
235
+ return runtimeServicesInfo?.mode ?? null;
236
+ }
237
+
238
+ /**
239
+ * Render the runtime services info into a markdown block suitable for
240
+ * appending to the system prompt via `AgentContext.system_message_suffix`.
241
+ *
242
+ * Returns `undefined` when no runtime info is available, so callers can safely
243
+ * omit the field when the selected backend does not advertise runtime services.
244
+ */
245
+ export function buildRuntimeServicesSystemSuffix(
246
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
247
+ ): string | undefined {
248
+ const info = parseRuntimeServicesInfo(runtimeServicesInfo);
249
+ if (!info?.services) return undefined;
250
+
251
+ const lines: string[] = [];
252
+ lines.push("<RUNTIME_SERVICES>");
253
+ if (info.mode) {
254
+ lines.push(
255
+ `You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`,
256
+ );
257
+ } else {
258
+ lines.push("You are running inside an agent-canvas dev stack.");
259
+ }
260
+ lines.push(
261
+ "The following services are reachable from your sandbox. URLs are written",
262
+ "from your point of view (i.e., as you should curl/fetch them).",
263
+ "",
264
+ );
265
+
266
+ const { agent_server, ingress, automation } = info.services;
267
+ const { frontend } = info.services;
268
+
269
+ if (agent_server?.url_from_agent) {
270
+ lines.push(
271
+ `* Agent Server (you): ${agent_server.url_from_agent}`,
272
+ ` ${agent_server.description ?? "The agent-server hosting your tool calls."}`,
273
+ );
274
+ }
275
+ if (ingress?.url_from_agent) {
276
+ lines.push(
277
+ `* Ingress: ${ingress.url_from_agent}`,
278
+ ` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`,
279
+ );
280
+ }
281
+ if (frontend?.url_from_agent) {
282
+ lines.push(
283
+ `* Frontend: ${frontend.url_from_agent}`,
284
+ ` ${frontend.description ?? "Frontend dev server."}`,
285
+ );
286
+ }
287
+ if (automation?.url_from_agent) {
288
+ lines.push(
289
+ `* Automation backend: ${automation.url_from_agent}`,
290
+ ` ${automation.description ?? "OpenHands Automations service."}`,
291
+ );
292
+ if (automation.docs_url) {
293
+ lines.push(` Docs: ${automation.docs_url}`);
294
+ }
295
+ if (automation.openapi_url) {
296
+ lines.push(` OpenAPI: ${automation.openapi_url}`);
297
+ }
298
+ if (automation.auth_env_var) {
299
+ // X-Session-API-Key is the local convention shared by the agent-server
300
+ // and automation backend (see openhands-automation auth.py).
301
+ lines.push(
302
+ ` Auth: header 'X-Session-API-Key: $${automation.auth_env_var}'`,
303
+ );
304
+ }
305
+ } else {
306
+ lines.push(
307
+ "* Automation backend: not running in this dev mode (skip /api/automation calls).",
308
+ );
309
+ }
310
+
311
+ // Anchor the "don't guess" warning to the actual agent-server URL for
312
+ // this stack instead of a hardcoded port. The agent-server listens on
313
+ // different ports across dev modes, and baking the wrong port into the
314
+ // system prompt is exactly the kind of confusion this block is meant to
315
+ // prevent.
316
+ const agentServerUrl = agent_server?.url_from_agent;
317
+ lines.push(
318
+ "",
319
+ "Trust this block over guessing: do not assume any other URLs are running.",
320
+ );
321
+ if (agentServerUrl) {
322
+ lines.push(
323
+ `In particular, ${agentServerUrl} inside your sandbox is the Agent Server`,
324
+ "you are running inside of — NOT the automation backend.",
325
+ );
326
+ }
327
+ lines.push("</RUNTIME_SERVICES>");
328
+
329
+ return lines.join("\n");
330
+ }
331
+
332
+ export function toConversationUrl(conversationId: string): string {
333
+ // Local-format conversation URL — points at whichever local agent-server
334
+ // is actually serving the conversation (the bundled one when the active
335
+ // selection is cloud).
336
+ const { host } = getAgentServerClientOptions();
337
+ return `${host}/api/conversations/${conversationId}`;
338
+ }
339
+
340
+ // TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE
341
+ // with `{{shortId}}` interpolation. Kept as a literal for now to keep the
342
+ // fallback inside this pure adapter rather than fanning out to display sites.
343
+ export function getDefaultConversationTitle(conversationId: string): string {
344
+ return `Conversation ${conversationId.slice(0, 5)}`;
345
+ }
346
+
347
+ export function toAppConversation(
348
+ info: DirectConversationInfo,
349
+ ): AppConversation {
350
+ const metadata = getStoredConversationMetadata(info.id);
351
+ // ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the
352
+ // runtime model fields when available, then the configured ``acp_model`` that
353
+ // Canvas saves for built-in providers. ``agent_kind`` still gates model
354
+ // switching, so surfacing this string is display-only.
355
+ const isAcp = info.agent?.kind === "ACPAgent";
356
+ // Only surface ``acp_server`` for ACP conversations even if the wire
357
+ // payload accidentally carries an ``acpserver`` tag on an OpenHands
358
+ // conversation — the chip is identity info for the ACP CLI subprocess,
359
+ // and showing it on a non-ACP conversation would be a lie. Fall back to the
360
+ // agent's own ``acp_server`` (#3692) when the tag is missing — a profile
361
+ // launch doesn't stamp the tag, so tag-only reads would drop the ACP model
362
+ // picker and degrade the chip to a generic "ACP" (#1571).
363
+ const acpServer = isAcp
364
+ ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? info.agent?.acp_server ?? null)
365
+ : null;
366
+ return {
367
+ id: info.id,
368
+ created_by_user_id: null,
369
+ selected_repository: metadata?.selected_repository ?? null,
370
+ selected_branch: metadata?.selected_branch ?? null,
371
+ git_provider: metadata?.git_provider ?? null,
372
+ selected_workspace: metadata?.selected_workspace ?? null,
373
+ active_profile: metadata?.active_profile ?? null,
374
+ title: info.title?.trim()
375
+ ? info.title
376
+ : getDefaultConversationTitle(info.id),
377
+ trigger: null,
378
+ pr_number: [],
379
+ agent_kind: isAcp ? "acp" : "openhands",
380
+ acp_server: acpServer,
381
+ tags: info.tags ?? null,
382
+ launched_agent_profile: info.launched_agent_profile ?? null,
383
+ // Chip path: omit ``providerDefault`` so that when no concrete model
384
+ // resolves, the chip falls back to the provider display name in
385
+ // ConversationCardFooter rather than a registry default the session may
386
+ // not actually be running.
387
+ llm_model: isAcp
388
+ ? resolveEffectiveAcpModel({
389
+ runtimeName: info.current_model_name,
390
+ runtimeId: info.current_model_id,
391
+ configured: info.agent?.acp_model,
392
+ sdkLlm: info.agent?.llm?.model,
393
+ })
394
+ : (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model),
395
+ metrics: info.metrics
396
+ ? {
397
+ accumulated_cost: info.metrics.accumulated_cost ?? null,
398
+ max_budget_per_task: info.metrics.max_budget_per_task ?? null,
399
+ accumulated_token_usage: info.metrics.accumulated_token_usage
400
+ ? {
401
+ prompt_tokens:
402
+ info.metrics.accumulated_token_usage.prompt_tokens ?? 0,
403
+ completion_tokens:
404
+ info.metrics.accumulated_token_usage.completion_tokens ?? 0,
405
+ cache_read_tokens:
406
+ info.metrics.accumulated_token_usage.cache_read_tokens ?? 0,
407
+ cache_write_tokens:
408
+ info.metrics.accumulated_token_usage.cache_write_tokens ?? 0,
409
+ context_window:
410
+ info.metrics.accumulated_token_usage.context_window ?? 0,
411
+ per_turn_token:
412
+ info.metrics.accumulated_token_usage.per_turn_token ?? 0,
413
+ }
414
+ : null,
415
+ }
416
+ : combineUsageMetrics(info.stats),
417
+ created_at: info.created_at,
418
+ updated_at: info.updated_at,
419
+ execution_status:
420
+ (info.execution_status as AppConversation["execution_status"]) ??
421
+ ExecutionStatus.IDLE,
422
+ sandbox_status: (info.sandbox_status as SandboxStatus | null) ?? null,
423
+ conversation_url: toConversationUrl(info.id),
424
+ session_api_key: getAgentServerClientOptions().apiKey ?? null,
425
+ sandbox_id: null,
426
+ workspace: {
427
+ working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(),
428
+ },
429
+ public: false,
430
+ sub_conversation_ids: info.sub_conversation_ids ?? [],
431
+ };
432
+ }
433
+
434
+ export function toConversationPage(data: {
435
+ items: DirectConversationInfo[];
436
+ next_page_id?: string | null;
437
+ }): AppConversationPage {
438
+ return {
439
+ items: data.items
440
+ .filter((item) => !item.tags?.[LOCAL_PLANNER_PARENT_TAG_KEY])
441
+ .map(toAppConversation),
442
+ next_page_id: data.next_page_id ?? null,
443
+ };
444
+ }
445
+
446
+ type SettingsRecord = Record<string, unknown>;
447
+
448
+ interface AgentToolSpec {
449
+ name: string;
450
+ params: SettingsRecord;
451
+ }
452
+
453
+ type AgentSettingsPayload = SettingsRecord & {
454
+ llm?: SettingsRecord;
455
+ agent_context: SettingsRecord;
456
+ tools?: AgentToolSpec[];
457
+ };
458
+
459
+ interface LocalWorkspacePayload {
460
+ kind: "LocalWorkspace";
461
+ working_dir: string;
462
+ }
463
+
464
+ interface InitialMessagePayload {
465
+ role: "user";
466
+ content: Array<{ type: "text"; text: string }>;
467
+ run: true;
468
+ }
469
+
470
+ type ConversationSettingsPayload = SettingsRecord & {
471
+ workspace: LocalWorkspacePayload;
472
+ initial_message?: InitialMessagePayload;
473
+ };
474
+
475
+ export const ACP_SERVER_TAG_KEY = "acpserver";
476
+ export const CLIENT_SOURCE_TAG_KEY = "clientsource";
477
+ export const AGENT_CANVAS_SOURCE = "agentcanvas";
478
+
479
+ export const AUTOMATION_TRIGGER_TAG_KEY = "automationtrigger";
480
+ export const AUTOMATION_ID_TAG_KEY = "automationid";
481
+ export const AUTOMATION_NAME_TAG_KEY = "automationname";
482
+ export const AUTOMATION_RUN_ID_TAG_KEY = "automationrunid";
483
+
484
+ /**
485
+ * Tag keys stamped on conversations created by automation runs (see the SDK's
486
+ * `RemoteWorkspace.default_conversation_tags`). The presence of any of these
487
+ * marks a conversation as automation-born.
488
+ */
489
+ export const AUTOMATION_TAG_KEYS: readonly string[] = [
490
+ AUTOMATION_TRIGGER_TAG_KEY,
491
+ AUTOMATION_ID_TAG_KEY,
492
+ AUTOMATION_NAME_TAG_KEY,
493
+ AUTOMATION_RUN_ID_TAG_KEY,
494
+ ];
495
+
496
+ /**
497
+ * Conversation tag keys that must not appear as generic chips / hovercard
498
+ * rows. Each is either already surfaced by a first-class UI source or is
499
+ * internal routing data:
500
+ * - ``acpserver`` → ACP provider chip
501
+ * - ``clientsource`` → telemetry attribution
502
+ * - ``title`` → conversation card heading
503
+ * - git / repo / branch / workspace stamps → repo-branch metadata + directory
504
+ * footer / hovercard rows (``selected_repository``, ``selected_branch``,
505
+ * ``git_provider``, ``workspace.working_dir``)
506
+ * - the automation family (``automationtrigger`` / ``automationid`` /
507
+ * ``automationname`` / ``automationrunid``) → provenance the SDK stamps at
508
+ * creation; the conversation panel's automation filter is its first-class
509
+ * UI source. The tag surface is user organization data, so machine stamps
510
+ * stay out of it — and users can't edit or spoof automation classification.
511
+ * - ``localplannerparent`` → internal routing for the local planner; already
512
+ * surfaced by the hidden-from-list planner filter
513
+ */
514
+ export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet<string> = new Set([
515
+ ACP_SERVER_TAG_KEY,
516
+ CLIENT_SOURCE_TAG_KEY,
517
+ AUTOMATION_TRIGGER_TAG_KEY,
518
+ AUTOMATION_ID_TAG_KEY,
519
+ AUTOMATION_NAME_TAG_KEY,
520
+ AUTOMATION_RUN_ID_TAG_KEY,
521
+ "title",
522
+ "git_provider",
523
+ "repo_name",
524
+ "repo",
525
+ "repository",
526
+ "selected_branch",
527
+ "branch",
528
+ "archiveworkspacepath",
529
+ "workspace",
530
+ "working_dir",
531
+ LOCAL_PLANNER_PARENT_TAG_KEY,
532
+ ]);
533
+
534
+ /**
535
+ * High-signal tag keys shown first in the chip row (before A–Z). Automations
536
+ * often stamp ``origin``; remaining free-form tags sort alphabetically.
537
+ */
538
+ export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"];
539
+
540
+ /**
541
+ * User-facing subset of a conversation's server-side tags: everything except
542
+ * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries.
543
+ * Priority keys come first (in {@link PRIORITY_CONVERSATION_TAG_KEYS} order);
544
+ * the rest sort A–Z so chip order doesn't shuffle between refetches.
545
+ */
546
+ export function getDisplayConversationTags(
547
+ tags: Record<string, string> | null | undefined,
548
+ ): Array<[string, string]> {
549
+ if (!tags) {
550
+ return [];
551
+ }
552
+ // Both the reserved-key check and the priority lookup must see the same
553
+ // normalized key: a cloud backend can stamp ``Origin`` / `` origin``, and
554
+ // ranking those off the raw key would silently drop them out of first place.
555
+ const priorityRank = (key: string): number => {
556
+ const index = PRIORITY_CONVERSATION_TAG_KEYS.indexOf(
557
+ key.trim().toLowerCase(),
558
+ );
559
+ return index === -1 ? Number.POSITIVE_INFINITY : index;
560
+ };
561
+
562
+ return Object.entries(tags)
563
+ .filter(
564
+ ([key, value]) =>
565
+ !RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) &&
566
+ typeof value === "string" &&
567
+ // Bare tags (empty value) are displayable — chips/tooltips render
568
+ // the key. Whitespace-only values stay dropped (raw-write junk).
569
+ (value === "" || value.trim().length > 0),
570
+ )
571
+ .sort(([a], [b]) => {
572
+ const aRank = priorityRank(a);
573
+ const bRank = priorityRank(b);
574
+ if (aRank !== bRank) {
575
+ return aRank - bRank;
576
+ }
577
+ return a.localeCompare(b);
578
+ });
579
+ }
580
+
581
+ const FERNET_TOKEN_PREFIX = "gAAAAA";
582
+
583
+ const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([
584
+ "schema_version",
585
+ "agent_settings",
586
+ "workspace",
587
+ "conversation_id",
588
+ "initial_message",
589
+ "plugins",
590
+ ]);
591
+
592
+ function toRecord(value: unknown): SettingsRecord {
593
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
594
+ return {};
595
+ }
596
+
597
+ return structuredClone(value as SettingsRecord);
598
+ }
599
+
600
+ function normalizeSecretString(value: unknown): string | undefined {
601
+ if (typeof value !== "string") {
602
+ return undefined;
603
+ }
604
+
605
+ const trimmed = value.trim();
606
+ return trimmed.length > 0 ? trimmed : undefined;
607
+ }
608
+
609
+ function buildNormalizedLlmSettings(value: unknown): SettingsRecord {
610
+ const llm = toRecord(value);
611
+
612
+ llm.model =
613
+ typeof llm.model === "string" && llm.model.trim().length > 0
614
+ ? llm.model
615
+ : DEFAULT_SETTINGS.llm_model;
616
+
617
+ const apiKey = normalizeSecretString(llm.api_key);
618
+ if (apiKey) {
619
+ llm.api_key = apiKey;
620
+ } else {
621
+ delete llm.api_key;
622
+ }
623
+
624
+ const baseUrl = normalizeSecretString(llm.base_url);
625
+ if (baseUrl) {
626
+ llm.base_url = baseUrl;
627
+ } else {
628
+ delete llm.base_url;
629
+ }
630
+
631
+ if (isSubscriptionLlmConfig(llm)) {
632
+ llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION;
633
+ llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR;
634
+ delete llm.api_key;
635
+ } else {
636
+ delete llm.auth_type;
637
+ delete llm.subscription_vendor;
638
+ }
639
+
640
+ return llm;
641
+ }
642
+
643
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
644
+ return !!value && typeof value === "object" && !Array.isArray(value);
645
+ }
646
+
647
+ function hasEncryptedString(value: unknown): boolean {
648
+ if (typeof value === "string") {
649
+ return value.startsWith(FERNET_TOKEN_PREFIX);
650
+ }
651
+ if (Array.isArray(value)) {
652
+ return value.some(hasEncryptedString);
653
+ }
654
+ if (isPlainRecord(value)) {
655
+ return Object.values(value).some(hasEncryptedString);
656
+ }
657
+ return false;
658
+ }
659
+
660
+ function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean {
661
+ if (!isPlainRecord(mcpConfig)) {
662
+ return false;
663
+ }
664
+
665
+ return Object.values(mcpConfig).some(hasEncryptedString);
666
+ }
667
+
668
+ function getConversationConfirmationPolicy(
669
+ conversationSettings: SettingsRecord,
670
+ ) {
671
+ if (conversationSettings.confirmation_mode !== true) {
672
+ return { kind: "NeverConfirm" };
673
+ }
674
+
675
+ if (conversationSettings.security_analyzer === "llm") {
676
+ return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true };
677
+ }
678
+
679
+ return { kind: "AlwaysConfirm" };
680
+ }
681
+
682
+ function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) {
683
+ switch (conversationSettings.security_analyzer) {
684
+ case "llm":
685
+ return { kind: "LLMSecurityAnalyzer" };
686
+ case "pattern":
687
+ return { kind: "PatternSecurityAnalyzer" };
688
+ case "policy_rail":
689
+ return { kind: "PolicyRailSecurityAnalyzer" };
690
+ default:
691
+ return undefined;
692
+ }
693
+ }
694
+
695
+ function isToolRecord(
696
+ value: unknown,
697
+ ): value is { name: string; params?: unknown } {
698
+ return (
699
+ !!value &&
700
+ typeof value === "object" &&
701
+ !Array.isArray(value) &&
702
+ typeof (value as { name?: unknown }).name === "string"
703
+ );
704
+ }
705
+
706
+ function shouldIncludeTool(name: string, agentSettings: SettingsRecord) {
707
+ if (name === BROWSER_TOOL_SET_NAME) {
708
+ return browserToolsEnabled() && isAgentServerToolAvailable(name);
709
+ }
710
+
711
+ if (name === TASK_TOOL_SET_NAME) {
712
+ return (
713
+ agentSettings.enable_sub_agents === true &&
714
+ isAgentServerToolAvailable(name)
715
+ );
716
+ }
717
+
718
+ return true;
719
+ }
720
+
721
+ function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] {
722
+ const tools = new Map<string, AgentToolSpec>();
723
+
724
+ for (const name of DEFAULT_TOOL_NAMES) {
725
+ if (shouldIncludeTool(name, agentSettings)) {
726
+ tools.set(name, { name, params: {} });
727
+ }
728
+ }
729
+
730
+ for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) {
731
+ if (shouldIncludeTool(name, agentSettings)) {
732
+ tools.set(name, { name, params: {} });
733
+ }
734
+ }
735
+
736
+ const configuredTools = agentSettings.tools;
737
+ if (
738
+ Array.isArray(configuredTools) &&
739
+ configuredTools.every((tool) => isToolRecord(tool))
740
+ ) {
741
+ for (const tool of configuredTools) {
742
+ if (shouldIncludeTool(tool.name, agentSettings)) {
743
+ tools.set(tool.name, {
744
+ name: tool.name,
745
+ params: toRecord(tool.params),
746
+ });
747
+ }
748
+ }
749
+ }
750
+
751
+ return Array.from(tools.values());
752
+ }
753
+
754
+ function buildInitialMessage(
755
+ query?: string,
756
+ conversationInstructions?: string,
757
+ ): InitialMessagePayload | null {
758
+ const parts = [query?.trim(), conversationInstructions?.trim()].filter(
759
+ Boolean,
760
+ );
761
+ if (parts.length === 0) {
762
+ return null;
763
+ }
764
+
765
+ return {
766
+ role: "user",
767
+ content: [{ type: "text", text: parts.join("\n\n") }],
768
+ run: true,
769
+ };
770
+ }
771
+
772
+ /**
773
+ * Shape of a bundled skill entry passed to the agent-server SDK via
774
+ * `agent_context.skills`. Mirrors the SDK's `Skill` model fields that
775
+ * the server uses for trigger matching, activation, and system-prompt
776
+ * injection.
777
+ */
778
+ interface BundledSkill {
779
+ name: string;
780
+ content: string;
781
+ trigger: { type: "keyword"; keywords: string[] } | null;
782
+ source: string;
783
+ description: string | null;
784
+ is_agentskills_format: true;
785
+ license?: string;
786
+ compatibility?: string;
787
+ }
788
+
789
+ /**
790
+ * Convert the bundled `SKILLS_CATALOG` entries into the SDK `Skill` JSON
791
+ * shape so the agent-server can perform trigger matching, skill activation,
792
+ * and system-prompt injection without cloning the extensions repo.
793
+ *
794
+ * The SDK discriminates triggers via `{ type: "keyword", keywords: [...] }`.
795
+ * Skills with no triggers get `trigger: null` (always-active / on-demand).
796
+ */
797
+ function buildBundledSkills(): BundledSkill[] {
798
+ return SKILLS_CATALOG.map((entry) => {
799
+ const trigger: BundledSkill["trigger"] =
800
+ entry.triggers?.length > 0
801
+ ? { type: "keyword", keywords: entry.triggers }
802
+ : null;
803
+
804
+ // Use the absolute path to the skill's SKILL.md so the Python
805
+ // agent-server can resolve bundled resources (scripts/, references/).
806
+ // Falls back to "public" in library builds where the path isn't known.
807
+ const source = __EXTENSIONS_SKILLS_DIR__
808
+ ? `${__EXTENSIONS_SKILLS_DIR__}/${entry.name}/SKILL.md`
809
+ : "public";
810
+
811
+ return {
812
+ name: entry.name,
813
+ content: entry.content,
814
+ trigger,
815
+ source,
816
+ description: entry.description ?? null,
817
+ is_agentskills_format: true as const,
818
+ ...(entry.license ? { license: entry.license } : {}),
819
+ ...(entry.compatibility ? { compatibility: entry.compatibility } : {}),
820
+ };
821
+ });
822
+ }
823
+
824
+ function buildAgentContext(
825
+ agentSettings: SettingsRecord,
826
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
827
+ enablement: SkillEnablement = {},
828
+ invokedCatalogSkill?: string,
829
+ ): SettingsRecord {
830
+ const runtimeServicesSuffix =
831
+ buildRuntimeServicesSystemSuffix(runtimeServicesInfo);
832
+ const existingContext = toRecord(agentSettings.agent_context);
833
+
834
+ // Merge bundled public skills with any skills already present in the
835
+ // agent context (e.g. user-defined skills set via the settings API).
836
+ const existingSkills = Array.isArray(existingContext.skills)
837
+ ? (existingContext.skills as SettingsRecord[])
838
+ : [];
839
+ const disabledSkills = enablement.disabledSkills ?? [];
840
+ const disabledSkillNames = new Set(disabledSkills);
841
+ const isSkillEnabled = buildSkillEnablementFilter(enablement);
842
+
843
+ // The bundled catalog is allow-listed, not deny-listed: it is a build-time
844
+ // snapshot of ~60 skills, so a deny-list puts every future addition into
845
+ // every system prompt (OpenHands#16302). Skills the agent context already
846
+ // carries are user-authored and stay opt-out. A skill the opening message
847
+ // invokes by name overrides both, for this conversation only.
848
+ const mergedSkills = [
849
+ ...existingSkills.filter(
850
+ (skill) =>
851
+ typeof skill.name !== "string" || !disabledSkillNames.has(skill.name),
852
+ ),
853
+ ...buildBundledSkills().filter(
854
+ (skill) =>
855
+ skill.name === invokedCatalogSkill || isSkillEnabled(skill.name),
856
+ ),
857
+ ];
858
+
859
+ return {
860
+ ...existingContext,
861
+ // Public skills are bundled at build time from the @openhands/extensions
862
+ // npm package and passed directly in agent_context.skills. Setting
863
+ // load_public_skills to false tells the agent-server SDK to skip its own
864
+ // extensions-repo clone — the frontend is the sole source of public
865
+ // skills now.
866
+ //
867
+ // Migration: the former VITE_LOAD_PUBLIC_SKILLS env var was removed
868
+ // because bundled skills have no clone latency. Users who previously set
869
+ // VITE_LOAD_PUBLIC_SKILLS=false to avoid clone delays no longer need it.
870
+ skills: mergedSkills,
871
+ load_public_skills: false,
872
+ load_user_skills: true,
873
+ load_project_skills: true,
874
+ // The backend also auto-loads user/project skills; the deny-list must
875
+ // travel with the context so those skills are excluded from the system
876
+ // prompt too. The allow-list has no counterpart to send: the backend
877
+ // loads no catalog skills of its own (`load_public_skills` is false).
878
+ disabled_skills: disabledSkills,
879
+ ...(runtimeServicesSuffix
880
+ ? { system_message_suffix: runtimeServicesSuffix }
881
+ : {}),
882
+ };
883
+ }
884
+
885
+ function isAcpAgent(settings: Settings): boolean {
886
+ const agentSettings = toRecord(settings.agent_settings);
887
+ return agentSettings.agent_kind === "acp";
888
+ }
889
+
890
+ function getAcpServerTag(settings: Settings): string | undefined {
891
+ const agentSettings = toRecord(settings.agent_settings);
892
+ const value = agentSettings.acp_server;
893
+ return typeof value === "string" && value.length > 0 ? value : undefined;
894
+ }
895
+
896
+ function resolveAcpCommand(agentSettings: SettingsRecord): unknown {
897
+ const cmd = agentSettings.acp_command;
898
+ const isEmpty = Array.isArray(cmd) && cmd.length === 0;
899
+ const noCommand = cmd === undefined;
900
+ if (!isEmpty && !noCommand) {
901
+ return cmd;
902
+ }
903
+
904
+ const serverKey =
905
+ typeof agentSettings.acp_server === "string"
906
+ ? agentSettings.acp_server
907
+ : undefined;
908
+ const provider = getAcpProvider(serverKey);
909
+ return provider ? [...provider.default_command] : cmd;
910
+ }
911
+
912
+ function buildConfiguredAcpAgentSettings(
913
+ settings: Settings,
914
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
915
+ query?: string,
916
+ ): AgentSettingsPayload {
917
+ const agentSettings = toRecord(settings.agent_settings);
918
+ const payload: AgentSettingsPayload = {
919
+ agent_kind: "acp",
920
+ agent_context: buildAgentContext(
921
+ agentSettings,
922
+ runtimeServicesInfo,
923
+ toSkillEnablement(settings),
924
+ findInvokedCatalogSkill(query),
925
+ ),
926
+ };
927
+
928
+ // TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized
929
+ // backend so concurrent same-provider conversations don't race on a shared
930
+ // HOME. The SDK supports it (software-agent-sdk#3492), but the released
931
+ // ``@openhands/typescript-client`` (1.24.3) doesn't surface it on
932
+ // ``ACPAgentSettings`` yet, so sending it risks a validation error on older
933
+ // servers. Cloud grouping isolation is separate (agent-canvas#1016).
934
+
935
+ for (const key of ACP_SETTINGS_KEYS) {
936
+ // ``acp_model`` is resolved separately below so a saved ``null`` still
937
+ // falls back to the provider's default rather than being dropped.
938
+ if (key === "acp_model") continue;
939
+ // ``acp_env`` is deprecated — provider creds now route via ``request.secrets``.
940
+ if (key === "acp_env") continue;
941
+ const value =
942
+ key === "acp_command"
943
+ ? resolveAcpCommand(agentSettings)
944
+ : agentSettings[key];
945
+ if (value !== undefined && value !== null) {
946
+ payload[key] = value;
947
+ }
948
+ }
949
+
950
+ // ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it
951
+ // so the ACP subprocess connects to the configured MCP servers at session
952
+ // creation. Only include it when it actually carries servers — an empty or
953
+ // malformed value is dropped rather than sending ``mcp_config: {}``.
954
+ const mcpConfig = toRecord(agentSettings.mcp_config);
955
+ if (Object.keys(mcpConfig).length > 0) {
956
+ payload.mcp_config = mcpConfig;
957
+ }
958
+
959
+ // Saved settings may carry ``acp_model: null`` (existing users predating
960
+ // the default-model registry, or saved fields the agent-server stripped).
961
+ // Fall back to the *preferred* default (Vertex-safe for Gemini) so the
962
+ // conversation starts with whatever the Settings → Agent UI shows — without
963
+ // that, the form's displayed default would silently not take effect at
964
+ // runtime until the user re-saved the page.
965
+ const serverKey =
966
+ typeof agentSettings.acp_server === "string"
967
+ ? agentSettings.acp_server
968
+ : undefined;
969
+ const effectiveModel = resolveEffectiveAcpModel({
970
+ configured: agentSettings.acp_model as string | null | undefined,
971
+ providerDefault: getAcpPreferredDefaultModel(serverKey),
972
+ });
973
+ if (effectiveModel) {
974
+ payload.acp_model = effectiveModel;
975
+ }
976
+
977
+ return payload;
978
+ }
979
+
980
+ function buildConfiguredOpenHandsAgentSettings(
981
+ settings: Settings,
982
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
983
+ query?: string,
984
+ ): AgentSettingsPayload {
985
+ const agentSettings = toRecord(settings.agent_settings);
986
+ const llm = buildNormalizedLlmSettings(agentSettings.llm);
987
+
988
+ // Stream assistant tokens (parity with ACP agents). The agent-server only
989
+ // emits StreamingDeltaEvents for SDK LLM agents when an LLM has stream=True.
990
+ llm.stream = true;
991
+
992
+ const mcpConfig = toRecord(agentSettings.mcp_config);
993
+ if (Object.keys(mcpConfig).length === 0) {
994
+ delete agentSettings.mcp_config;
995
+ }
996
+
997
+ delete agentSettings.acp_server;
998
+ for (const key of ACP_SETTINGS_KEYS) {
999
+ delete agentSettings[key];
1000
+ }
1001
+ // ``acp_env`` is no longer a forwarded ACP setting (provider creds ride the
1002
+ // Secrets panel), but a legacy value may linger on persisted settings —
1003
+ // scrub it so it never leaks onto the OpenHands payload.
1004
+ delete agentSettings.acp_env;
1005
+
1006
+ return {
1007
+ ...agentSettings,
1008
+ llm,
1009
+ agent_context: buildAgentContext(
1010
+ agentSettings,
1011
+ runtimeServicesInfo,
1012
+ toSkillEnablement(settings),
1013
+ findInvokedCatalogSkill(query),
1014
+ ),
1015
+ tools: getAgentTools(agentSettings),
1016
+ };
1017
+ }
1018
+
1019
+ function buildConfiguredAgentSettings(
1020
+ settings: Settings,
1021
+ runtimeServicesInfo?: RuntimeServicesInfo | null,
1022
+ query?: string,
1023
+ ): AgentSettingsPayload {
1024
+ return isAcpAgent(settings)
1025
+ ? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo, query)
1026
+ : buildConfiguredOpenHandsAgentSettings(
1027
+ settings,
1028
+ runtimeServicesInfo,
1029
+ query,
1030
+ );
1031
+ }
1032
+
1033
+ function buildConfiguredConversationSettings(options: {
1034
+ settings: Settings;
1035
+ query?: string;
1036
+ conversationInstructions?: string;
1037
+ plugins?: PluginSpec[];
1038
+ workingDir?: string;
1039
+ }): ConversationSettingsPayload {
1040
+ const { settings, query, conversationInstructions, plugins, workingDir } =
1041
+ options;
1042
+ const conversationSettings = toRecord(settings.conversation_settings);
1043
+ const initialMessage = buildInitialMessage(query, conversationInstructions);
1044
+
1045
+ CONVERSATION_SETTINGS_METADATA_KEYS.forEach(
1046
+ (key) => delete conversationSettings[key],
1047
+ );
1048
+
1049
+ const payload: ConversationSettingsPayload = {
1050
+ ...conversationSettings,
1051
+ workspace: {
1052
+ kind: "LocalWorkspace",
1053
+ working_dir: workingDir ?? getAgentServerWorkingDir(),
1054
+ },
1055
+ ...(initialMessage ? { initial_message: initialMessage } : {}),
1056
+ ...(plugins?.length
1057
+ ? {
1058
+ plugins: plugins.map((plugin) => ({
1059
+ source: plugin.source,
1060
+ ...(plugin.ref ? { ref: plugin.ref } : {}),
1061
+ ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}),
1062
+ })),
1063
+ }
1064
+ : {}),
1065
+ };
1066
+
1067
+ return payload;
1068
+ }
1069
+
1070
+ interface LookupSecret {
1071
+ kind: "LookupSecret";
1072
+ url: string;
1073
+ headers?: Record<string, string>;
1074
+ description?: string;
1075
+ }
1076
+
1077
+ /** A custom secret's public identity — name + optional description, no value. */
1078
+ type CustomSecretInput = { name: string; description?: string };
1079
+
1080
+ type StartConversationPayloadBase = Record<string, unknown> & {
1081
+ workspace: LocalWorkspacePayload;
1082
+ confirmation_policy: SettingsRecord;
1083
+ security_analyzer?: SettingsRecord;
1084
+ initial_message?: InitialMessagePayload;
1085
+ max_iterations: number;
1086
+ stuck_detection: true;
1087
+ autotitle: boolean;
1088
+ title_llm_profile?: string;
1089
+ worktree: boolean;
1090
+ secrets_encrypted?: true;
1091
+ conversation_id?: string;
1092
+ parent_conversation_id?: string;
1093
+ secrets?: Record<string, LookupSecret>;
1094
+ tags?: Record<string, string>;
1095
+ client_tools: ClientToolSpec[];
1096
+ tool_module_qualnames?: Record<string, string>;
1097
+ };
1098
+
1099
+ type AgentSettingsStartConversationPayload = StartConversationPayloadBase & {
1100
+ // Omitted when launching via ``agent_profile_id`` — the two are mutually
1101
+ // exclusive agent sources; the server resolves the profile server-side.
1102
+ agent_settings?: AgentSettingsPayload;
1103
+ agent_profile_id?: string;
1104
+ agent?: never;
1105
+ };
1106
+
1107
+ // The local planner bypasses agent assembly entirely and sends a raw agent
1108
+ // spec, so neither of the two managed agent sources applies.
1109
+ type RawAgentStartConversationPayload = StartConversationPayloadBase & {
1110
+ agent: SettingsRecord;
1111
+ agent_settings?: never;
1112
+ agent_profile_id?: never;
1113
+ parent_conversation_id?: string;
1114
+ };
1115
+
1116
+ export interface StartConversationOptions {
1117
+ settings: Settings;
1118
+ query?: string;
1119
+ conversationInstructions?: string;
1120
+ plugins?: PluginSpec[];
1121
+ conversationId?: string;
1122
+ // Links the new conversation to an existing one as its child. The
1123
+ // agent-server requires the parent to exist and to share this
1124
+ // conversation's requested `workspace.working_dir` (software-agent-sdk
1125
+ // #4188, agent-server >= 1.37.1); older servers ignore the field.
1126
+ parentConversationId?: string;
1127
+ workingDir?: string;
1128
+ worktree?: boolean;
1129
+ encryptedAgentSettings?: Record<string, SettingsValue>;
1130
+ encryptedConversationSettings?: Record<string, SettingsValue>;
1131
+ secretsEncrypted?: boolean;
1132
+ customSecrets?: CustomSecretInput[];
1133
+ // When set, the conversation launches from this AgentProfile (resolved
1134
+ // server-side) instead of an inline ``agent_settings`` dump (#3727).
1135
+ agentProfileId?: string;
1136
+ agentProfileKind?: AgentKind;
1137
+ titleLlmProfile?: string;
1138
+ runtimeServicesInfo?: RuntimeServicesInfo | null;
1139
+ workspaceHookConfig?: HookConfig | null;
1140
+ }
1141
+
1142
+ /**
1143
+ * Build the `request.secrets` map shared by the standard and planning
1144
+ * conversation builders. Every saved secret rides as a LookupSecret the
1145
+ * agent-server resolves from its own store at spawn time — `request.secrets` is
1146
+ * the sole channel, uniform for ACP and non-ACP (agent-canvas#1039). For ACP the
1147
+ * resolution runs off the event loop (software-agent-sdk#3510, >=1.25.0), so the
1148
+ * loopback fetch can't deadlock. Returns `undefined` when there are no custom
1149
+ * secrets so callers can omit the field.
1150
+ */
1151
+ function buildCustomSecrets(
1152
+ customSecrets: CustomSecretInput[] | undefined,
1153
+ ): Record<string, LookupSecret> | undefined {
1154
+ if (!customSecrets?.length) return undefined;
1155
+
1156
+ const backend = getEffectiveLocalBackend();
1157
+ const headers = backend ? buildAuthHeaders(backend) : {};
1158
+
1159
+ const secrets: Record<string, LookupSecret> = {};
1160
+ for (const secret of customSecrets) {
1161
+ const lookupSecret: LookupSecret = {
1162
+ kind: "LookupSecret",
1163
+ url: `/api/settings/secrets/${encodeURIComponent(secret.name)}`,
1164
+ description: secret.description,
1165
+ };
1166
+ if (Object.keys(headers).length > 0) {
1167
+ lookupSecret.headers = headers;
1168
+ }
1169
+ secrets[secret.name] = lookupSecret;
1170
+ }
1171
+ return secrets;
1172
+ }
1173
+
1174
+ export function buildStartConversationRequest(
1175
+ options: StartConversationOptions,
1176
+ ): AgentSettingsStartConversationPayload {
1177
+ const sourceAgentSettings = options.encryptedAgentSettings
1178
+ ? { ...options.settings, agent_settings: options.encryptedAgentSettings }
1179
+ : options.settings;
1180
+
1181
+ const acpMode = isAcpAgent(sourceAgentSettings);
1182
+ const launchAgentKind = options.agentProfileId
1183
+ ? options.agentProfileKind
1184
+ : acpMode
1185
+ ? "acp"
1186
+ : "openhands";
1187
+ const agentSettings = buildConfiguredAgentSettings(
1188
+ sourceAgentSettings,
1189
+ options.runtimeServicesInfo,
1190
+ options.query,
1191
+ );
1192
+ const acpServerTag = acpMode
1193
+ ? getAcpServerTag(sourceAgentSettings)
1194
+ : undefined;
1195
+
1196
+ const sourceConversationOptions = options.encryptedConversationSettings
1197
+ ? {
1198
+ ...options,
1199
+ settings: {
1200
+ ...options.settings,
1201
+ conversation_settings: options.encryptedConversationSettings,
1202
+ },
1203
+ }
1204
+ : options;
1205
+
1206
+ const conversationSettings = buildConfiguredConversationSettings(
1207
+ sourceConversationOptions,
1208
+ );
1209
+
1210
+ const payload: AgentSettingsStartConversationPayload = {
1211
+ // ``agent_profile_id`` and ``agent_settings`` are mutually exclusive agent
1212
+ // sources; the profile path lets the server resolve the profile (#3727).
1213
+ //
1214
+ // Enrichment boundary: on the profile path the server rebuilds the agent
1215
+ // purely from the stored profile fields, so the client-owned enrichments
1216
+ // this adapter folds into ``agent_settings`` do NOT apply. The exec toolset
1217
+ // (terminal/file_editor/task_tracker) and public-skill loading are the
1218
+ // server/SDK's responsibility to restore on the profile path — tracked in
1219
+ // software-agent-sdk#3967 (profile resolution must attach the default
1220
+ // toolset + public skills, else a profile-launched OpenHands agent has only
1221
+ // Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains
1222
+ // agent-settings-only; the Canvas UI tool is a top-level client tool and
1223
+ // therefore works on both inline-agent and profile launch paths.
1224
+ //
1225
+ // Persistent memory is NOT on that boundary: ``load_memory`` is a global
1226
+ // user preference, so the agent-server stamps the stored
1227
+ // ``agent_settings.agent_context.load_memory`` onto the profile-resolved
1228
+ // agent the same way it already applies the global ``mcp_config``. The
1229
+ // toggle therefore applies to both launch paths, and the client must not
1230
+ // re-send it here (``agent_profile_id`` and ``agent_settings`` are
1231
+ // mutually exclusive).
1232
+ ...(options.agentProfileId
1233
+ ? { agent_profile_id: options.agentProfileId }
1234
+ : { agent_settings: agentSettings }),
1235
+ workspace: conversationSettings.workspace,
1236
+ // The agent-server caches each client tool's schema per tool *name* for the
1237
+ // life of the process and rejects a re-registration with a different schema
1238
+ // (`ClientToolSchemaConflictError`). Editing either schema below therefore
1239
+ // requires restarting a long-running dev agent-server before new
1240
+ // conversations can start.
1241
+ client_tools:
1242
+ launchAgentKind === "openhands"
1243
+ ? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL]
1244
+ : [],
1245
+ confirmation_policy:
1246
+ getConversationConfirmationPolicy(conversationSettings),
1247
+ max_iterations: resolveMaxIterations(conversationSettings.max_iterations),
1248
+ stuck_detection: true,
1249
+ autotitle: true,
1250
+ ...(options.titleLlmProfile
1251
+ ? { title_llm_profile: options.titleLlmProfile }
1252
+ : {}),
1253
+ worktree: options.worktree ?? true,
1254
+ };
1255
+
1256
+ // Stamp the client source tag so the agent-server can attribute the
1257
+ // conversation to Canvas in telemetry (conversation_source = "canvas").
1258
+ // A profile launch resolves the ACP server server-side, so don't stamp the
1259
+ // tag from current settings (it may not match the launched profile).
1260
+ if (!options.agentProfileId && acpServerTag) {
1261
+ payload.tags = {
1262
+ [ACP_SERVER_TAG_KEY]: acpServerTag,
1263
+ [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
1264
+ };
1265
+ } else {
1266
+ payload.tags = { [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE };
1267
+ }
1268
+
1269
+ // ``secrets_encrypted`` makes the agent-server decrypt request secrets at
1270
+ // conversation start. Non-ACP conversations need it for encrypted LLM keys.
1271
+ // ACP normally carries provider credentials as LookupSecrets, so avoid
1272
+ // forcing a cipher on fresh ACP-only backends. The exception is MCP:
1273
+ // encrypted settings round-trip mcp_config secrets as Fernet tokens,
1274
+ // and ACP forwards mcp_config directly to the subprocess.
1275
+ if (
1276
+ !options.agentProfileId &&
1277
+ options.secretsEncrypted &&
1278
+ (!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config))
1279
+ ) {
1280
+ payload.secrets_encrypted = true;
1281
+ }
1282
+
1283
+ if (options.conversationId) {
1284
+ payload.conversation_id = options.conversationId;
1285
+ }
1286
+
1287
+ if (options.parentConversationId) {
1288
+ payload.parent_conversation_id = options.parentConversationId;
1289
+ }
1290
+
1291
+ const securityAnalyzer =
1292
+ getConversationSecurityAnalyzer(conversationSettings);
1293
+ if (securityAnalyzer) {
1294
+ payload.security_analyzer = securityAnalyzer;
1295
+ }
1296
+
1297
+ if (conversationSettings.initial_message) {
1298
+ payload.initial_message = conversationSettings.initial_message;
1299
+ }
1300
+
1301
+ if (conversationSettings.plugins) {
1302
+ payload.plugins = conversationSettings.plugins;
1303
+ }
1304
+
1305
+ if (conversationSettings.hook_config) {
1306
+ payload.hook_config = conversationSettings.hook_config;
1307
+ } else if (options.workspaceHookConfig) {
1308
+ payload.hook_config = options.workspaceHookConfig;
1309
+ }
1310
+
1311
+ const toolModuleQualnames = {
1312
+ ...((conversationSettings.tool_module_qualnames as
1313
+ | Record<string, string>
1314
+ | undefined) ?? {}),
1315
+ };
1316
+ delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME];
1317
+ delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME];
1318
+ delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME];
1319
+ if (Object.keys(toolModuleQualnames).length > 0) {
1320
+ payload.tool_module_qualnames = toolModuleQualnames;
1321
+ }
1322
+
1323
+ if (conversationSettings.agent_definitions) {
1324
+ payload.agent_definitions = conversationSettings.agent_definitions;
1325
+ }
1326
+
1327
+ const secrets = buildCustomSecrets(options.customSecrets);
1328
+ if (secrets) {
1329
+ payload.secrets = secrets;
1330
+ }
1331
+
1332
+ return payload;
1333
+ }
1334
+
1335
+ export function buildStartPlanningConversationRequest(options: {
1336
+ encryptedAgentSettings: Record<string, SettingsValue>;
1337
+ workingDir: string;
1338
+ parentConversationId: string;
1339
+ initialMessage?: string;
1340
+ secretsEncrypted?: boolean;
1341
+ customSecrets?: CustomSecretInput[];
1342
+ /** Mirrors the parent's configured `conversation_settings.max_iterations` — see DEFAULT_MAX_ITERATIONS. */
1343
+ maxIterations?: number;
1344
+ /** Mirrors the code agent's skill filtering — see buildConfiguredOpenHandsAgentSettings. */
1345
+ skillEnablement?: SkillEnablement;
1346
+ }): RawAgentStartConversationPayload {
1347
+ const agentSettings = toRecord(options.encryptedAgentSettings);
1348
+ const llm = buildNormalizedLlmSettings(agentSettings.llm);
1349
+ // Stream assistant tokens, matching the code agent (see
1350
+ // buildConfiguredOpenHandsAgentSettings) — otherwise the agent-server never
1351
+ // emits StreamingDeltaEvents for the planner and its replies would appear
1352
+ // all at once instead of token-by-token.
1353
+ llm.stream = true;
1354
+
1355
+ const planPath = buildPlanPath(options.workingDir);
1356
+
1357
+ // Put the planner's directive + boundaries in the system prompt (matching the
1358
+ // OpenHands app-server's PLANNING_AGENT_INSTRUCTION), preserving any suffix
1359
+ // buildAgentContext already set (e.g. the runtime-services block).
1360
+ const agentContext = buildAgentContext(
1361
+ agentSettings,
1362
+ undefined,
1363
+ options.skillEnablement,
1364
+ );
1365
+ const existingSuffix = agentContext.system_message_suffix;
1366
+ agentContext.system_message_suffix =
1367
+ typeof existingSuffix === "string"
1368
+ ? `${PLANNING_AGENT_INSTRUCTION}\n\n${existingSuffix}`
1369
+ : PLANNING_AGENT_INSTRUCTION;
1370
+
1371
+ // Idle planner: "Create a Plan" only switches to plan mode and provisions
1372
+ // this conversation; the user sends the first message themselves, so nothing
1373
+ // is injected into the chat. An explicit initialMessage is still honored.
1374
+ const initialMessage = buildInitialMessage(options.initialMessage);
1375
+
1376
+ const payload: RawAgentStartConversationPayload = {
1377
+ agent: {
1378
+ kind: "Agent",
1379
+ llm,
1380
+ tools: [
1381
+ { name: "glob", params: {} },
1382
+ { name: "grep", params: {} },
1383
+ {
1384
+ name: PLANNING_FILE_EDITOR_TOOL_NAME,
1385
+ params: { plan_path: planPath },
1386
+ },
1387
+ ],
1388
+ system_prompt_filename: PLANNING_SYSTEM_PROMPT_FILENAME,
1389
+ system_prompt_kwargs: { plan_structure: PLAN_STRUCTURE_TEXT },
1390
+ // agent_context (skills + project context) is intentionally included so
1391
+ // the planner shares the code agent's project context. This goes beyond
1392
+ // the bare SDK `get_planning_agent()` preset (which sets no context); it
1393
+ // mirrors how the frontend builds every agent, so the local planner is
1394
+ // not context-starved relative to the code agent.
1395
+ agent_context: agentContext,
1396
+ // Mirror the SDK planning preset's condenser (openhands-tools preset
1397
+ // `get_planning_condenser`): a larger rolling window and more pinned
1398
+ // initial context than the default, with its own usage_id so its
1399
+ // summarization LLM calls are attributed separately. The planning
1400
+ // conversation is sent as a raw agent spec (it bypasses the agent-server's
1401
+ // default agent assembly), so without this it would never condense —
1402
+ // diverging from the canonical planning agent on long sessions.
1403
+ condenser: {
1404
+ kind: "LLMSummarizingCondenser",
1405
+ llm: { ...llm, usage_id: "planning_condenser" },
1406
+ max_size: 100,
1407
+ keep_first: 6,
1408
+ },
1409
+ },
1410
+ workspace: {
1411
+ kind: "LocalWorkspace",
1412
+ working_dir: options.workingDir,
1413
+ },
1414
+ // No Canvas UI client tool: the planner's only output is PLAN.md, which the
1415
+ // Planner tab surfaces on its own via PlanningFileEditorObservation. Handing
1416
+ // it the panel-control tool would only let it navigate the right-side panel
1417
+ // away from the plan the user is reading.
1418
+ client_tools: [],
1419
+ confirmation_policy: { kind: "NeverConfirm" },
1420
+ max_iterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS,
1421
+ stuck_detection: true,
1422
+ autotitle: false,
1423
+ worktree: false,
1424
+ // Two independent links to the parent, for two different jobs:
1425
+ //
1426
+ // - ``parent_conversation_id`` is the server-owned relationship (SDK
1427
+ // #4188, agent-server >= 1.37.1). The parent's ``sub_conversation_ids``
1428
+ // is derived from it, so the planner is recoverable from the server on
1429
+ // any browser and after storage loss. Older agent-servers ignore the
1430
+ // field (``StartConversationRequest`` does not forbid extras), which is
1431
+ // why the client-side metadata hint is still written as a fallback.
1432
+ // - The tag is what hides the helper from the conversation list; it is
1433
+ // also the only marker on agent-servers too old for the parent link.
1434
+ parent_conversation_id: options.parentConversationId,
1435
+ tags: { [LOCAL_PLANNER_PARENT_TAG_KEY]: options.parentConversationId },
1436
+ ...(initialMessage ? { initial_message: initialMessage } : {}),
1437
+ };
1438
+
1439
+ if (options.secretsEncrypted) {
1440
+ payload.secrets_encrypted = true;
1441
+ }
1442
+
1443
+ const secrets = buildCustomSecrets(options.customSecrets);
1444
+ if (secrets) {
1445
+ payload.secrets = secrets;
1446
+ }
1447
+
1448
+ return payload;
1449
+ }
1450
+
1451
+ /**
1452
+ * Resolve the encrypted LLM config for a named LLM profile, so a planner can
1453
+ * run the parent conversation's *current* model (`active_profile`, tracking
1454
+ * `/model` and `SwitchLLMTool`) rather than a stale launch-time value.
1455
+ * Returns `null` — caller falls back further — if unknown or lookup fails.
1456
+ */
1457
+ async function resolveLlmProfileSettings(
1458
+ profileName: string,
1459
+ ): Promise<SettingsRecord | null> {
1460
+ try {
1461
+ const { default: ProfilesService } =
1462
+ await import("./profiles-service/profiles-service.api");
1463
+ // ``encrypted`` matches ``secrets_encrypted`` on the payload: the
1464
+ // agent-server decrypts with the same cipher it encrypted with.
1465
+ const detail = await ProfilesService.getProfile(profileName, "encrypted");
1466
+ return isPlainRecord(detail.config) ? detail.config : null;
1467
+ } catch (error) {
1468
+ console.warn(
1469
+ `Falling back: could not resolve LLM profile ${profileName}`,
1470
+ error,
1471
+ );
1472
+ return null;
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Resolve the encrypted LLM config the given AgentProfile launches with, as a
1478
+ * fallback for `resolveLlmProfileSettings`. AgentProfiles hold a reference
1479
+ * (`llm_profile_ref`), not credentials, so this is a two-hop lookup: profile
1480
+ * id -> llm profile name -> encrypted LLM config. Returns `null` — caller
1481
+ * falls back to global settings — for an unknown/ACP/dangling-ref profile or
1482
+ * a failed lookup, none of which should block plan creation outright.
1483
+ */
1484
+ async function resolveAgentProfileLlmSettings(
1485
+ agentProfileId: string,
1486
+ ): Promise<SettingsRecord | null> {
1487
+ try {
1488
+ const { default: AgentProfilesService } =
1489
+ await import("./agent-profiles-service/agent-profiles-service.api");
1490
+
1491
+ const { profiles } = await AgentProfilesService.listProfiles();
1492
+ const summary = profiles.find(
1493
+ (profile) => profile.id === agentProfileId && profile.llm_profile_ref,
1494
+ );
1495
+ if (!summary?.llm_profile_ref) return null;
1496
+
1497
+ return await resolveLlmProfileSettings(summary.llm_profile_ref);
1498
+ } catch (error) {
1499
+ console.warn(
1500
+ `Falling back to global agent settings: could not resolve the LLM for agent profile ${agentProfileId}`,
1501
+ error,
1502
+ );
1503
+ return null;
1504
+ }
1505
+ }
1506
+
1507
+ export async function buildStartPlanningConversationRequestWithEncryptedSettings(options: {
1508
+ workingDir: string;
1509
+ parentConversationId: string;
1510
+ /**
1511
+ * The parent conversation's current LLM profile (`AppConversation.active_profile`,
1512
+ * tracking `/model` and `SwitchLLMTool`). Takes priority over
1513
+ * `parentAgentProfileId` so a model switch on the parent carries over to a
1514
+ * planner created afterward, without a *different* conversation's global
1515
+ * profile activation repointing it.
1516
+ */
1517
+ parentActiveProfileName?: string | null;
1518
+ /**
1519
+ * `launched_agent_profile.agent_profile_id` of the parent, when started
1520
+ * from an AgentProfile. Fallback for when `parentActiveProfileName` can't
1521
+ * be resolved (e.g. an ACP parent, whose `active_profile` is a stale
1522
+ * launch-time snapshot rather than anything the ACP agent itself runs).
1523
+ */
1524
+ parentAgentProfileId?: string | null;
1525
+ initialMessage?: string;
1526
+ }): Promise<RawAgentStartConversationPayload> {
1527
+ const { SecretsService } = await import("./secrets-service");
1528
+
1529
+ const [settingsResult, customSecrets] = await Promise.all([
1530
+ SettingsService.getSettingsForConversation(),
1531
+ SecretsService.getSecrets(),
1532
+ ]);
1533
+
1534
+ const profileLlm =
1535
+ (options.parentActiveProfileName
1536
+ ? await resolveLlmProfileSettings(options.parentActiveProfileName)
1537
+ : null) ??
1538
+ (options.parentAgentProfileId
1539
+ ? await resolveAgentProfileLlmSettings(options.parentAgentProfileId)
1540
+ : null);
1541
+ const encryptedAgentSettings: Record<string, SettingsValue> = profileLlm
1542
+ ? { ...settingsResult.agentSettings, llm: profileLlm as SettingsValue }
1543
+ : settingsResult.agentSettings;
1544
+
1545
+ await assertSubscriptionAuthReady(encryptedAgentSettings);
1546
+
1547
+ // Mirror the code agent's configured cap (see DEFAULT_MAX_ITERATIONS) rather
1548
+ // than hardcoding a different value the planner could silently stop against.
1549
+ const maxIterations = resolveMaxIterations(
1550
+ settingsResult.conversationSettings.max_iterations,
1551
+ );
1552
+
1553
+ return buildStartPlanningConversationRequest({
1554
+ ...options,
1555
+ encryptedAgentSettings,
1556
+ secretsEncrypted: settingsResult.secretsEncrypted,
1557
+ customSecrets,
1558
+ maxIterations,
1559
+ skillEnablement: settingsResult.skillEnablement,
1560
+ });
1561
+ }
1562
+
1563
+ export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR =
1564
+ "Connect your ChatGPT subscription before starting a conversation with this LLM profile.";
1565
+
1566
+ /**
1567
+ * Throws if a ChatGPT subscription LLM profile is not connected.
1568
+ * Called before conversation creation and LLM profile switch only — not on
1569
+ * subsequent message sends or conversation resume. The agent-server must handle
1570
+ * mid-conversation token expiry gracefully.
1571
+ */
1572
+ export async function assertSubscriptionAuthReady(
1573
+ agentSettings: Record<string, unknown>,
1574
+ ): Promise<void> {
1575
+ const llm = toRecord(agentSettings.llm);
1576
+ if (!isSubscriptionLlmConfig(llm)) return;
1577
+
1578
+ const status = await LLMSubscriptionService.getOpenAIStatus();
1579
+ if (!status.connected) {
1580
+ throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR);
1581
+ }
1582
+ }
1583
+
1584
+ export async function buildStartConversationRequestWithEncryptedSettings(options: {
1585
+ settings: Settings;
1586
+ query?: string;
1587
+ conversationInstructions?: string;
1588
+ plugins?: PluginSpec[];
1589
+ conversationId?: string;
1590
+ parentConversationId?: string;
1591
+ workingDir?: string;
1592
+ /** Workspace root for the hooks lookup, not the per-conversation `workingDir` (#16907). */
1593
+ hooksProjectDir?: string;
1594
+ worktree?: boolean;
1595
+ agentProfileId?: string;
1596
+ agentProfileKind?: AgentKind;
1597
+ titleLlmProfile?: string;
1598
+ }): Promise<Record<string, unknown>> {
1599
+ const [{ SecretsService }, { default: HooksService }] = await Promise.all([
1600
+ import("./secrets-service"),
1601
+ import("./hooks-service"),
1602
+ ]);
1603
+
1604
+ const [
1605
+ settingsResult,
1606
+ customSecrets,
1607
+ runtimeServicesInfo,
1608
+ workspaceHookConfig,
1609
+ ] = await Promise.all([
1610
+ SettingsService.getSettingsForConversation(),
1611
+ SecretsService.getSecrets(),
1612
+ fetchBackendRuntimeServicesInfo(),
1613
+ HooksService.loadWorkspaceHooks(options.hooksProjectDir),
1614
+ ]);
1615
+
1616
+ const { agentSettings, conversationSettings, secretsEncrypted } =
1617
+ settingsResult;
1618
+
1619
+ // A profile launch resolves the LLM server-side, so the current-settings
1620
+ // subscription check doesn't apply (and can't see the profile's LLM).
1621
+ if (!options.agentProfileId) {
1622
+ await assertSubscriptionAuthReady(agentSettings);
1623
+ }
1624
+
1625
+ return buildStartConversationRequest({
1626
+ ...options,
1627
+ encryptedAgentSettings: agentSettings,
1628
+ encryptedConversationSettings: conversationSettings,
1629
+ secretsEncrypted,
1630
+ customSecrets,
1631
+ runtimeServicesInfo,
1632
+ workspaceHookConfig,
1633
+ });
1634
+ }
1635
+
1636
+ export function emptyHooksResponse(): GetHooksResponse {
1637
+ return { hooks: [] };
1638
+ }
src/api/agent-server-client-options.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { buildHttpBaseUrl } from "#/utils/websocket-url";
2
+ import { getAgentServerWorkingDir } from "./agent-server-config";
3
+ import { getEffectiveLocalBackend } from "./backend-registry/active-store";
4
+ import type { Backend } from "./backend-registry/types";
5
+
6
+ export interface AgentServerClientOverrides {
7
+ host?: string;
8
+ apiKey?: string | null;
9
+ sessionApiKey?: string | null;
10
+ workingDir?: string;
11
+ conversationUrl?: string | null;
12
+ timeout?: number;
13
+ }
14
+
15
+ export interface AgentServerClientOptions {
16
+ host: string;
17
+ apiKey?: string;
18
+ workingDir: string;
19
+ timeout?: number;
20
+ }
21
+
22
+ export class NoBackendAvailableError extends Error {
23
+ constructor() {
24
+ super("No backend is configured.");
25
+ this.name = "NoBackendAvailableError";
26
+ }
27
+ }
28
+
29
+ export const isNoBackendAvailableError = (
30
+ error: unknown,
31
+ ): error is NoBackendAvailableError =>
32
+ error instanceof NoBackendAvailableError ||
33
+ (typeof error === "object" &&
34
+ error !== null &&
35
+ "name" in error &&
36
+ error.name === "NoBackendAvailableError");
37
+
38
+ function normalizeHost(host: string): string {
39
+ return host.replace(/\/+$/, "");
40
+ }
41
+
42
+ function resolveHost(
43
+ overrides: AgentServerClientOverrides,
44
+ backend: Backend | null,
45
+ ): string {
46
+ if (overrides.host) return normalizeHost(overrides.host);
47
+ if (overrides.conversationUrl)
48
+ return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl));
49
+ return normalizeHost(backend?.host ?? "");
50
+ }
51
+
52
+ export function getAgentServerClientOptions(
53
+ overrides: AgentServerClientOverrides = {},
54
+ ): AgentServerClientOptions {
55
+ const backend = getEffectiveLocalBackend();
56
+ if (!backend && !overrides.host && !overrides.conversationUrl) {
57
+ throw new NoBackendAvailableError();
58
+ }
59
+
60
+ const apiKey =
61
+ overrides.sessionApiKey ?? overrides.apiKey ?? backend?.apiKey ?? undefined;
62
+
63
+ return {
64
+ host: resolveHost(overrides, backend),
65
+ ...(apiKey ? { apiKey } : {}),
66
+ workingDir: overrides.workingDir ?? getAgentServerWorkingDir(),
67
+ ...(overrides.timeout !== undefined ? { timeout: overrides.timeout } : {}),
68
+ };
69
+ }
70
+
71
+ export function getAgentServerHttpClientOptions(
72
+ overrides?: AgentServerClientOverrides,
73
+ ) {
74
+ const { host, apiKey, timeout } = getAgentServerClientOptions(overrides);
75
+ return {
76
+ baseUrl: host,
77
+ ...(apiKey ? { apiKey } : {}),
78
+ timeout: timeout ?? 60000,
79
+ };
80
+ }
src/api/agent-server-compatibility.test.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ AgentServerUnknownVersionError,
4
+ AgentServerUnsupportedVersionError,
5
+ assertAgentServerVersionIsSupported,
6
+ getDisplayAgentServerSdkVersion,
7
+ getDisplayAgentServerVersion,
8
+ type AgentServerInfo,
9
+ } from "./agent-server-compatibility";
10
+
11
+ const serverInfo = (version?: string, sdkVersion?: string): AgentServerInfo =>
12
+ ({ version, sdk_version: sdkVersion }) as AgentServerInfo;
13
+
14
+ describe("agent-server version compatibility", () => {
15
+ it("classifies missing and unknown versions separately from old versions", () => {
16
+ for (const version of [undefined, "", "unknown", " UNKNOWN "]) {
17
+ expect(() =>
18
+ assertAgentServerVersionIsSupported(serverInfo(version)),
19
+ ).toThrow(AgentServerUnknownVersionError);
20
+ }
21
+ });
22
+
23
+ it("classifies malformed versions separately from old versions", () => {
24
+ expect(() =>
25
+ assertAgentServerVersionIsSupported(serverInfo("dev-build")),
26
+ ).toThrow(AgentServerUnknownVersionError);
27
+ });
28
+
29
+ it("keeps valid but too-old versions on the unsupported-version path", () => {
30
+ expect(() =>
31
+ assertAgentServerVersionIsSupported(serverInfo("0.0.1")),
32
+ ).toThrow(AgentServerUnsupportedVersionError);
33
+ });
34
+
35
+ it("does not render unknown or malformed versions as backend badges", () => {
36
+ expect(getDisplayAgentServerVersion(serverInfo("unknown"))).toBeNull();
37
+ expect(getDisplayAgentServerVersion(serverInfo("dev-build"))).toBeNull();
38
+ expect(getDisplayAgentServerVersion(serverInfo("1.28.0"))).toBe("1.28.0");
39
+ });
40
+
41
+ it("uses sdk_version as the agent-server version fallback", () => {
42
+ expect(getDisplayAgentServerVersion(serverInfo(undefined, "1.36.1"))).toBe(
43
+ "1.36.1",
44
+ );
45
+ expect(() =>
46
+ assertAgentServerVersionIsSupported(serverInfo(undefined, "1.36.1")),
47
+ ).not.toThrow();
48
+ });
49
+
50
+ it("reports the dedicated agent-server SDK version when present", () => {
51
+ expect(
52
+ getDisplayAgentServerSdkVersion(serverInfo("1.36.0", "1.36.1")),
53
+ ).toBe("1.36.1");
54
+ expect(getDisplayAgentServerSdkVersion(serverInfo("1.36.0"))).toBe(
55
+ "1.36.0",
56
+ );
57
+ });
58
+ });
src/api/agent-server-compatibility.ts ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ServerClient,
3
+ SettingsClient,
4
+ } from "@openhands/typescript-client/clients";
5
+ import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client";
6
+ import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
7
+ import { isAuthRequired } from "#/api/agent-server-config";
8
+ import {
9
+ getActiveBackend,
10
+ getEffectiveLocalBackend,
11
+ isNoBackend,
12
+ } from "#/api/backend-registry/active-store";
13
+ import type { Backend } from "#/api/backend-registry/types";
14
+ import defaults from "../../config/defaults.json";
15
+
16
+ const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
17
+ const UNKNOWN_AGENT_SERVER_VERSION = "unknown";
18
+
19
+ export const MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION =
20
+ defaults.compatibility.minimumAgentServer;
21
+ export const AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE =
22
+ "AGENT_SERVER_UNSUPPORTED_VERSION";
23
+ export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE =
24
+ "AGENT_SERVER_UNKNOWN_VERSION";
25
+
26
+ /**
27
+ * Sentinel string thrown (as an Error message) when a local backend
28
+ * rejects the configured session API key with HTTP 401.
29
+ * Shared between the backend health probe ({@link validateLocalBackend})
30
+ * and any consumer that needs to detect this specific failure.
31
+ */
32
+ export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
33
+
34
+ export interface AgentServerInfo extends BaseServerInfo {
35
+ sdk_version?: string;
36
+ usable_tools?: string[] | null;
37
+ runtime_services?: unknown;
38
+ }
39
+
40
+ let cachedAgentServerInfo: AgentServerInfo | null = null;
41
+ let cachedAgentServerInfoHost: string | null = null;
42
+
43
+ const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => {
44
+ if (Array.isArray(serverInfo?.usable_tools)) {
45
+ return serverInfo.usable_tools;
46
+ }
47
+ return null;
48
+ };
49
+
50
+ export class AgentServerUnavailableError extends Error {
51
+ readonly details: string | null;
52
+ readonly noBackendConfigured: boolean;
53
+
54
+ constructor(
55
+ details?: string | null,
56
+ options?: { noBackendConfigured?: boolean },
57
+ ) {
58
+ const noBackendConfigured = options?.noBackendConfigured ?? false;
59
+ super(
60
+ noBackendConfigured
61
+ ? "No agent server backend is configured yet. Add a backend to get started."
62
+ : "Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.",
63
+ );
64
+ this.name = "AgentServerUnavailableError";
65
+ this.details = details ?? null;
66
+ this.noBackendConfigured = noBackendConfigured;
67
+ }
68
+ }
69
+
70
+ export const isAgentServerUnavailableError = (
71
+ error: unknown,
72
+ ): error is AgentServerUnavailableError =>
73
+ error instanceof AgentServerUnavailableError ||
74
+ (typeof error === "object" &&
75
+ error !== null &&
76
+ "name" in error &&
77
+ (error.name === "AgentServerUnavailableError" ||
78
+ error.name === "AgentServerUnsupportedVersionError" ||
79
+ error.name === "AgentServerUnknownVersionError"));
80
+
81
+ export class AgentServerUnsupportedVersionError extends AgentServerUnavailableError {
82
+ readonly code = AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE;
83
+ readonly actualVersion: string;
84
+ readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;
85
+
86
+ constructor(actualVersion: string) {
87
+ const message = `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} or newer; this backend is running ${actualVersion}. Please upgrade the agent-server backend.`;
88
+ super(message);
89
+ this.name = "AgentServerUnsupportedVersionError";
90
+ this.message = message;
91
+ this.actualVersion = actualVersion;
92
+ }
93
+ }
94
+
95
+ export class AgentServerUnknownVersionError extends AgentServerUnavailableError {
96
+ readonly code = AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE;
97
+ readonly actualVersion: string | null;
98
+ readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;
99
+
100
+ constructor(actualVersion: string | null) {
101
+ const reported = actualVersion ? ` It reported "${actualVersion}".` : "";
102
+ const message =
103
+ `Could not determine this backend's agent-server version.${reported} ` +
104
+ `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} ` +
105
+ "or newer, but this backend did not return a valid version from " +
106
+ "/server_info. Restart or rebuild the agent-server backend, then try again.";
107
+ super(message);
108
+ this.name = "AgentServerUnknownVersionError";
109
+ this.message = message;
110
+ this.actualVersion = actualVersion;
111
+ }
112
+ }
113
+
114
+ export const isAgentServerUnsupportedVersionError = (
115
+ error: unknown,
116
+ ): error is AgentServerUnsupportedVersionError =>
117
+ error instanceof AgentServerUnsupportedVersionError ||
118
+ (typeof error === "object" &&
119
+ error !== null &&
120
+ "code" in error &&
121
+ error.code === AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE);
122
+
123
+ export const isAgentServerUnknownVersionError = (
124
+ error: unknown,
125
+ ): error is AgentServerUnknownVersionError =>
126
+ error instanceof AgentServerUnknownVersionError ||
127
+ (typeof error === "object" &&
128
+ error !== null &&
129
+ "code" in error &&
130
+ error.code === AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE);
131
+
132
+ /**
133
+ * Returns true when the agent-server probe failed with HTTP 401.
134
+ * In public mode this means the stored key is stale (server restarted
135
+ * with a different `LOCAL_BACKEND_API_KEY`). Only meaningful when
136
+ * auth is required — a 401 in local mode is a misconfiguration, not a
137
+ * key-rotation event. Uses {@link isAuthRequired} so both the build-time
138
+ * `VITE_AUTH_REQUIRED` flag and the runtime `window.__AGENT_CANVAS_AUTH_REQUIRED__`
139
+ * injection (used by pre-built static binaries) are honoured.
140
+ */
141
+ export const isAgentServerAuthError = (error: unknown): boolean =>
142
+ isAuthRequired() && isSdkHttpStatusError(error, 401);
143
+
144
+ export function clearCachedAgentServerInfo() {
145
+ cachedAgentServerInfo = null;
146
+ cachedAgentServerInfoHost = null;
147
+ }
148
+
149
+ export function getCachedAgentServerInfo(options?: {
150
+ host?: string | null;
151
+ }): AgentServerInfo | null {
152
+ if (options?.host && options.host !== cachedAgentServerInfoHost) {
153
+ return null;
154
+ }
155
+ return cachedAgentServerInfo;
156
+ }
157
+
158
+ export function isAgentServerToolAvailable(toolName: string) {
159
+ const availableTools = getAdvertisedTools(cachedAgentServerInfo);
160
+ if (!Array.isArray(availableTools)) {
161
+ return true;
162
+ }
163
+ return availableTools.includes(toolName);
164
+ }
165
+
166
+ export function isSdkHttpError(error: unknown) {
167
+ return (
168
+ error instanceof Error &&
169
+ error.name === "HttpError" &&
170
+ "status" in error &&
171
+ typeof error.status === "number"
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Narrows an SDK HTTP error to a specific status code.
177
+ * Use instead of manually casting `(err as { status: number }).status`.
178
+ */
179
+ export function isSdkHttpStatusError(error: unknown, status: number): boolean {
180
+ return (
181
+ isSdkHttpError(error) && (error as { status: number }).status === status
182
+ );
183
+ }
184
+
185
+ function normalizeAgentServerInfoVersion(version: unknown): string | null {
186
+ if (typeof version !== "string") return null;
187
+ const trimmed = version.trim();
188
+ return trimmed || null;
189
+ }
190
+
191
+ function getRawAgentServerSdkVersion(
192
+ serverInfo: AgentServerInfo,
193
+ ): string | null {
194
+ return normalizeAgentServerInfoVersion(serverInfo.sdk_version);
195
+ }
196
+
197
+ function getRawAgentServerVersion(serverInfo: AgentServerInfo): string | null {
198
+ return (
199
+ normalizeAgentServerInfoVersion(serverInfo.version) ??
200
+ getRawAgentServerSdkVersion(serverInfo)
201
+ );
202
+ }
203
+
204
+ function getComparableAgentServerVersion(
205
+ serverInfo: AgentServerInfo,
206
+ ): string | null {
207
+ const version = getRawAgentServerVersion(serverInfo);
208
+ if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
209
+ return null;
210
+ }
211
+ return version;
212
+ }
213
+
214
+ export function getDisplayAgentServerVersion(
215
+ serverInfo: AgentServerInfo,
216
+ ): string | null {
217
+ const version = getComparableAgentServerVersion(serverInfo);
218
+ if (!version || !parseAgentServerVersion(version)) {
219
+ return null;
220
+ }
221
+ return version;
222
+ }
223
+
224
+ export function getDisplayAgentServerSdkVersion(
225
+ serverInfo: AgentServerInfo,
226
+ ): string | null {
227
+ const version =
228
+ getRawAgentServerSdkVersion(serverInfo) ??
229
+ getComparableAgentServerVersion(serverInfo);
230
+ if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
231
+ return null;
232
+ }
233
+ return version;
234
+ }
235
+
236
+ export function getCachedAgentServerSdkVersion(
237
+ host?: string | null,
238
+ ): string | null {
239
+ if (!cachedAgentServerInfo) return null;
240
+ if (host && getEffectiveLocalBackend()?.host !== host) return null;
241
+ return getDisplayAgentServerSdkVersion(cachedAgentServerInfo);
242
+ }
243
+
244
+ export function getCachedAgentServerVersion(
245
+ host?: string | null,
246
+ ): string | null {
247
+ if (!cachedAgentServerInfo) return null;
248
+ if (host && getEffectiveLocalBackend()?.host !== host) return null;
249
+ return getDisplayAgentServerVersion(cachedAgentServerInfo);
250
+ }
251
+
252
+ export function compareAgentServerVersions(actual: string, required: string) {
253
+ const parsedActual = parseAgentServerVersion(actual);
254
+ const parsedRequired = parseAgentServerVersion(required);
255
+
256
+ if (!parsedActual || !parsedRequired) {
257
+ return null;
258
+ }
259
+
260
+ for (const key of ["major", "minor", "patch"] as const) {
261
+ if (parsedActual[key] > parsedRequired[key]) {
262
+ return 1;
263
+ }
264
+ if (parsedActual[key] < parsedRequired[key]) {
265
+ return -1;
266
+ }
267
+ }
268
+
269
+ if (parsedActual.prerelease && !parsedRequired.prerelease) {
270
+ return -1;
271
+ }
272
+ if (!parsedActual.prerelease && parsedRequired.prerelease) {
273
+ return 1;
274
+ }
275
+ if (parsedActual.prerelease && parsedRequired.prerelease) {
276
+ return parsedActual.prerelease.localeCompare(parsedRequired.prerelease);
277
+ }
278
+
279
+ return 0;
280
+ }
281
+
282
+ function parseAgentServerVersion(version: string) {
283
+ const trimmed = version.trim().replace(/^v/, "");
284
+ const [withoutBuild] = trimmed.split("+");
285
+ const [core, prerelease] = withoutBuild.split("-", 2);
286
+ const parts = core.split(".");
287
+
288
+ if (parts.length !== 3) {
289
+ return null;
290
+ }
291
+
292
+ const [major, minor, patch] = parts.map((part) => Number(part));
293
+ if (
294
+ ![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)
295
+ ) {
296
+ return null;
297
+ }
298
+
299
+ return { major, minor, patch, prerelease };
300
+ }
301
+
302
+ export function assertAgentServerVersionIsSupported(
303
+ serverInfo: AgentServerInfo,
304
+ ) {
305
+ const actualVersion = getComparableAgentServerVersion(serverInfo);
306
+ if (!actualVersion) {
307
+ clearCachedAgentServerInfo();
308
+ throw new AgentServerUnknownVersionError(
309
+ getRawAgentServerVersion(serverInfo),
310
+ );
311
+ }
312
+
313
+ const comparison = compareAgentServerVersions(
314
+ actualVersion,
315
+ MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION,
316
+ );
317
+
318
+ if (comparison === null) {
319
+ clearCachedAgentServerInfo();
320
+ throw new AgentServerUnknownVersionError(actualVersion);
321
+ }
322
+
323
+ if (comparison < 0) {
324
+ clearCachedAgentServerInfo();
325
+ throw new AgentServerUnsupportedVersionError(actualVersion);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Validates a local agent-server backend with a two-step probe:
331
+ * 1. GET /api/settings — authenticates the configured session API key;
332
+ * a 401 throws an Error with message {@link INVALID_BACKEND_API_KEY_ERROR}.
333
+ * 2. GET /server_info — asserts the server meets the minimum version floor.
334
+ *
335
+ * Returns the display version string reported by the server, or `null` when
336
+ * the server does not report a parseable version. Throws on any failure.
337
+ *
338
+ * Used by both the backend health poller and the backend-form connection test
339
+ * so that auth-check semantics (status codes, error messages) stay in one place.
340
+ */
341
+ export async function validateLocalBackend(
342
+ backend: Pick<Backend, "host" | "apiKey">,
343
+ timeout: number,
344
+ ): Promise<string | null> {
345
+ const clientOptions = getAgentServerClientOptions({
346
+ host: backend.host,
347
+ sessionApiKey: backend.apiKey || null,
348
+ timeout,
349
+ });
350
+
351
+ try {
352
+ await new SettingsClient(clientOptions).getSettings();
353
+ const serverInfo = await new ServerClient(clientOptions).getServerInfo();
354
+ assertAgentServerVersionIsSupported(serverInfo);
355
+ return getDisplayAgentServerVersion(serverInfo);
356
+ } catch (error) {
357
+ if (isSdkHttpStatusError(error, 401)) {
358
+ throw new Error(INVALID_BACKEND_API_KEY_ERROR);
359
+ }
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ export async function loadAgentServerInfo() {
365
+ // The probe is a *local* agent-server concern — it verifies the runtime
366
+ // hosting the GUI is reachable. It must NEVER run against the active
367
+ // backend when that backend is cloud, because cloud hosts don't
368
+ // expose /api/server_info and would fail with a CORS error besides.
369
+ const local = getEffectiveLocalBackend();
370
+ if (!local) {
371
+ clearCachedAgentServerInfo();
372
+
373
+ // Empty registry (NO_BACKEND sentinel) — the user has no backend
374
+ // configured at all. Throw so root.tsx shows the manage-backends
375
+ // modal instead of silently rendering a broken home page.
376
+ if (isNoBackend(getActiveBackend().backend)) {
377
+ throw new AgentServerUnavailableError("No backend configured", {
378
+ noBackendConfigured: true,
379
+ });
380
+ }
381
+
382
+ // Active backend is cloud — no local probe needed.
383
+ return null;
384
+ }
385
+
386
+ const clientOptions = getAgentServerClientOptions({
387
+ host: local.host,
388
+ sessionApiKey: local.apiKey || null,
389
+ timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
390
+ });
391
+ let serverInfo: AgentServerInfo;
392
+
393
+ try {
394
+ serverInfo = (await new ServerClient(
395
+ clientOptions,
396
+ ).getServerInfo()) as AgentServerInfo;
397
+ } catch (error) {
398
+ clearCachedAgentServerInfo();
399
+ // Preserve 401 so root.tsx can show the auth screen (public mode).
400
+ // All other HTTP errors (502, 503, etc.) mean the server is unreachable
401
+ // or misconfigured — treat them as unavailable.
402
+ if (isSdkHttpStatusError(error, 401)) {
403
+ throw error;
404
+ }
405
+
406
+ const details = error instanceof Error ? error.message : null;
407
+ throw new AgentServerUnavailableError(details);
408
+ }
409
+
410
+ assertAgentServerVersionIsSupported(serverInfo);
411
+
412
+ // /server_info is unprotected, so a stale session key still gets 200.
413
+ // In public mode, validate the key against a protected endpoint so a
414
+ // server restart with a new LOCAL_BACKEND_API_KEY surfaces immediately
415
+ // instead of letting the app load and fail on every subsequent call.
416
+ if (isAuthRequired()) {
417
+ try {
418
+ await new SettingsClient(clientOptions).getSettings();
419
+ } catch (error) {
420
+ // Only rethrow 401 — that means the stored key is invalid /
421
+ // rotated. Other HTTP errors (403, 5xx) and non-HTTP errors
422
+ // (network, timeout) are swallowed: the server *is* up (we just
423
+ // reached /server_info), so let the app proceed with an
424
+ // unvalidated key rather than blocking the UI.
425
+ // NOTE: If the connection drops between the /server_info and
426
+ // getSettings() probes, the app loads with an unvalidated key and
427
+ // subsequent 401s won't trigger the auth screen (they come from
428
+ // React Query hooks, not this bootstrap path). Acceptable for now
429
+ // since the window is narrow and a page refresh recovers.
430
+ if (isSdkHttpStatusError(error, 401)) {
431
+ throw error;
432
+ }
433
+
434
+ console.warn(
435
+ "[agent-server] getSettings() probe failed (non-401):",
436
+ error,
437
+ );
438
+ }
439
+ }
440
+
441
+ cachedAgentServerInfo = serverInfo;
442
+ cachedAgentServerInfoHost = clientOptions.host;
443
+ return serverInfo;
444
+ }
src/api/agent-server-config.test.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ DEFAULT_WORKING_DIR,
4
+ buildConversationWorkingDir,
5
+ buildConversationWorkingDirForBackend,
6
+ buildRelativeConversationWorkingDir,
7
+ isServedOriginHost,
8
+ } from "./agent-server-config";
9
+
10
+ describe("buildRelativeConversationWorkingDir", () => {
11
+ afterEach(() => vi.unstubAllEnvs());
12
+
13
+ it("ignores a baked absolute VITE_WORKING_DIR and stays relative", () => {
14
+ vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/.openhands/workspaces");
15
+ expect(buildRelativeConversationWorkingDir("abc-123")).toBe(
16
+ `${DEFAULT_WORKING_DIR}/abc123`,
17
+ );
18
+ });
19
+
20
+ it("strips dashes from the conversation id", () => {
21
+ expect(buildRelativeConversationWorkingDir("a-b-c")).toBe(
22
+ `${DEFAULT_WORKING_DIR}/abc`,
23
+ );
24
+ });
25
+ });
26
+
27
+ describe("buildConversationWorkingDir (baked default)", () => {
28
+ afterEach(() => vi.unstubAllEnvs());
29
+
30
+ it("uses a baked absolute dir as the base", () => {
31
+ vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/workspaces");
32
+ expect(buildConversationWorkingDir("abc-123")).toBe(
33
+ "/Users/someone/workspaces/abc123",
34
+ );
35
+ });
36
+
37
+ it("falls back to the relative default when nothing is baked", () => {
38
+ vi.stubEnv("VITE_WORKING_DIR", "");
39
+ expect(buildConversationWorkingDir("abc-123")).toBe(
40
+ `${DEFAULT_WORKING_DIR}/abc123`,
41
+ );
42
+ });
43
+ });
44
+
45
+ describe("isServedOriginHost", () => {
46
+ afterEach(() => vi.unstubAllEnvs());
47
+
48
+ it("matches the host that served this frontend", () => {
49
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
50
+ expect(isServedOriginHost("http://127.0.0.1:8000")).toBe(true);
51
+ });
52
+
53
+ it("normalizes a bare host against the served origin", () => {
54
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
55
+ // getAgentServerBaseUrl returns the already-normalized configured URL;
56
+ // a bare candidate host is normalized to http:// off-window.
57
+ expect(isServedOriginHost("127.0.0.1:8000")).toBe(true);
58
+ });
59
+
60
+ it("does not match a different host", () => {
61
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
62
+ expect(isServedOriginHost("https://cabin.liberty-labs.org")).toBe(false);
63
+ });
64
+
65
+ it("does not match when the backend host is empty", () => {
66
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
67
+ expect(isServedOriginHost("")).toBe(false);
68
+ expect(isServedOriginHost(null)).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("buildConversationWorkingDirForBackend", () => {
73
+ afterEach(() => vi.unstubAllEnvs());
74
+
75
+ it("uses the baked absolute default for the served-origin backend", () => {
76
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
77
+ vi.stubEnv(
78
+ "VITE_WORKING_DIR",
79
+ "/Users/me/.openhands/agent-canvas/workspaces",
80
+ );
81
+ expect(
82
+ buildConversationWorkingDirForBackend("abc-123", "http://127.0.0.1:8000"),
83
+ ).toBe("/Users/me/.openhands/agent-canvas/workspaces/abc123");
84
+ });
85
+
86
+ // Regression for the reviewed bug: the seeded `default-local` backend is
87
+ // mutable. Editing its host to a remote VM (while its id stays
88
+ // `default-local`) must NOT send the baked macOS path to that VM — this is
89
+ // the exact case that failed on our remote Linux backend (Cabin) with
90
+ // `mkdir: cannot create directory '/Users': Permission denied`.
91
+ it("uses the relative default for an edited default-local pointing at a remote host", () => {
92
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
93
+ vi.stubEnv(
94
+ "VITE_WORKING_DIR",
95
+ "/Users/me/.openhands/agent-canvas/workspaces",
96
+ );
97
+ // The user edited the seeded `default-local` entry to a remote Linux VM
98
+ // (home /home/daytona); its host no longer matches the served origin.
99
+ expect(
100
+ buildConversationWorkingDirForBackend(
101
+ "abc-123",
102
+ "https://cabin.liberty-labs.org",
103
+ ),
104
+ ).toBe(`${DEFAULT_WORKING_DIR}/abc123`);
105
+ });
106
+
107
+ it("uses the relative default for any separately-registered remote backend", () => {
108
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
109
+ vi.stubEnv("VITE_WORKING_DIR", "/Users/me/workspaces");
110
+ expect(
111
+ buildConversationWorkingDirForBackend(
112
+ "abc-123",
113
+ "https://sandbox.example.dev",
114
+ ),
115
+ ).toBe(`${DEFAULT_WORKING_DIR}/abc123`);
116
+ });
117
+ });
src/api/agent-server-config.ts ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DEFAULT_WORKING_DIR = "workspace/project";
2
+
3
+ export type LockedCloudAuthMode = "api-key" | "cookie";
4
+
5
+ export interface AgentServerFormDefaults {
6
+ baseUrl: string;
7
+ sessionApiKey: string;
8
+ }
9
+
10
+ // Window-global key the static server injects `--lock-to-cloud` into; kept
11
+ // module-private because only `getLockedCloudHost()` reads it. The static
12
+ // server (`scripts/static-server.mjs`) and its tests reference the literal
13
+ // string directly, not this constant.
14
+ const LOCK_TO_CLOUD_WINDOW_KEY = "__AGENT_CANVAS_LOCK_TO_CLOUD__";
15
+ const LEGACY_CLOUD_DOMAIN = "all-hands.dev";
16
+ const CURRENT_CLOUD_DOMAIN = "openhands.dev";
17
+ const LEGACY_PRODUCTION_APP_HOST = `app.${LEGACY_CLOUD_DOMAIN}`;
18
+ const CURRENT_PRODUCTION_APP_HOST = CURRENT_CLOUD_DOMAIN;
19
+ const PRODUCTION_APP_HOST_ALIAS = `app.${CURRENT_CLOUD_DOMAIN}`;
20
+
21
+ function trimToNull(value?: string | null): string | null {
22
+ return value?.trim() || null;
23
+ }
24
+
25
+ function normalizeBaseUrl(value?: string | null): string | null {
26
+ if (!value) return null;
27
+
28
+ const trimmed = value.trim().replace(/\/$/, "");
29
+ if (!trimmed) return null;
30
+
31
+ if (/^https?:\/\//i.test(trimmed)) {
32
+ return trimmed;
33
+ }
34
+
35
+ if (typeof window !== "undefined") {
36
+ return `${window.location.protocol}//${trimmed}`;
37
+ }
38
+
39
+ return `http://${trimmed}`;
40
+ }
41
+
42
+ function normalizeCloudHost(value?: string | null): string | null {
43
+ if (!value) return null;
44
+
45
+ const trimmed = value.trim().replace(/\/+$/, "");
46
+ if (!trimmed) return null;
47
+
48
+ if (/^https?:\/\//i.test(trimmed)) {
49
+ return trimmed;
50
+ }
51
+
52
+ return `https://${trimmed}`;
53
+ }
54
+
55
+ function canonicalizeCloudHostname(hostname: string): string {
56
+ const lower = hostname.toLowerCase();
57
+ if (
58
+ lower === LEGACY_PRODUCTION_APP_HOST ||
59
+ lower === PRODUCTION_APP_HOST_ALIAS
60
+ ) {
61
+ return CURRENT_PRODUCTION_APP_HOST;
62
+ }
63
+
64
+ if (lower === LEGACY_CLOUD_DOMAIN) return CURRENT_CLOUD_DOMAIN;
65
+ if (lower.endsWith(`.${LEGACY_CLOUD_DOMAIN}`)) {
66
+ return `${lower.slice(0, -LEGACY_CLOUD_DOMAIN.length)}${CURRENT_CLOUD_DOMAIN}`;
67
+ }
68
+
69
+ return lower;
70
+ }
71
+
72
+ function getCloudHostComparisonKey(value?: string | null): string | null {
73
+ const normalized = normalizeCloudHost(value);
74
+ if (!normalized) return null;
75
+
76
+ try {
77
+ const url = new URL(normalized);
78
+ const port = url.port ? `:${url.port}` : "";
79
+ return `${url.protocol}//${canonicalizeCloudHostname(url.hostname)}${port}`;
80
+ } catch {
81
+ return normalized.toLowerCase();
82
+ }
83
+ }
84
+
85
+ export function getCookieAuthCloudHost(): string | null {
86
+ const lockedHost = getLockedCloudHost();
87
+ if (
88
+ !lockedHost ||
89
+ typeof window === "undefined" ||
90
+ !isSameCloudHost(window.location.origin, lockedHost)
91
+ ) {
92
+ return null;
93
+ }
94
+
95
+ return window.location.origin;
96
+ }
97
+
98
+ function getConfiguredBaseUrl(): string | null {
99
+ return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL);
100
+ }
101
+
102
+ /**
103
+ * Return the session API key supplied by the deployment host.
104
+ *
105
+ * Two sources are consulted, in order:
106
+ * 1. `VITE_SESSION_API_KEY` — baked into the bundle at build time (used by
107
+ * `npm run dev` so the dev server has the key without a round-trip).
108
+ * 2. `window.__AGENT_CANVAS_SESSION_API_KEY__` — injected into `index.html`
109
+ * at serve time by `scripts/static-server.mjs --session-api-key <key>`.
110
+ * This is the path used by the published `agent-canvas` binary, where
111
+ * `VITE_SESSION_API_KEY` is empty in the prebuilt bundle and the
112
+ * runtime key is generated when the user launches the CLI.
113
+ *
114
+ * Without the window-global fallback, the published binary cannot construct a
115
+ * default local backend (`makeDefaultLocalBackend()` returns null), the
116
+ * registry is left empty, and the user sees the Manage Backends modal
117
+ * instead of the onboarding flow.
118
+ */
119
+ export function getBakedSessionApiKey(): string | null {
120
+ const envKey = trimToNull(import.meta.env.VITE_SESSION_API_KEY);
121
+ if (envKey) return envKey;
122
+
123
+ if (typeof window !== "undefined") {
124
+ const injected = (window as unknown as Record<string, unknown>)
125
+ .__AGENT_CANVAS_SESSION_API_KEY__;
126
+ if (typeof injected === "string") {
127
+ return trimToNull(injected);
128
+ }
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ export function getAgentServerFormDefaults(): AgentServerFormDefaults {
135
+ return {
136
+ baseUrl: getAgentServerBaseUrl() ?? "",
137
+ sessionApiKey: getAgentServerSessionApiKey() ?? "",
138
+ };
139
+ }
140
+
141
+ export function getLockedCloudHost(): string | null {
142
+ const envHost = normalizeCloudHost(import.meta.env.VITE_LOCK_TO_CLOUD);
143
+ if (envHost) return envHost;
144
+
145
+ if (typeof window !== "undefined") {
146
+ const injected = (window as unknown as Record<string, unknown>)[
147
+ LOCK_TO_CLOUD_WINDOW_KEY
148
+ ];
149
+ if (typeof injected === "string") {
150
+ return normalizeCloudHost(injected);
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Compare a backend host against the locked Cloud host, normalizing
159
+ * trailing slashes, protocol, and case so that e.g.
160
+ * `https://app.all-hands.dev/` matches `https://app.all-hands.dev`.
161
+ *
162
+ * Used by the locked-to-Cloud gates (`root.tsx`,
163
+ * `onboarding-modal.tsx`) to decide whether the active backend is the
164
+ * configured locked Cloud host — a Cloud backend on a *different* host
165
+ * (or a stale Local backend) must not be treated as the locked backend.
166
+ */
167
+ export function isSameCloudHost(
168
+ host: string | null | undefined,
169
+ lockedHost: string | null | undefined,
170
+ ): boolean {
171
+ const a = getCloudHostComparisonKey(host);
172
+ const b = getCloudHostComparisonKey(lockedHost);
173
+ if (!a || !b) return false;
174
+ return a === b;
175
+ }
176
+
177
+ export function getLockedCloudAuthMode(): LockedCloudAuthMode {
178
+ return getCookieAuthCloudHost() ? "cookie" : "api-key";
179
+ }
180
+
181
+ export function getAgentServerBaseUrl(): string | null {
182
+ const configuredUrl = getConfiguredBaseUrl();
183
+ if (configuredUrl) return configuredUrl;
184
+
185
+ if (typeof window !== "undefined") {
186
+ return window.location.origin;
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ export function getAgentServerSessionApiKey(): string | null {
193
+ return getBakedSessionApiKey();
194
+ }
195
+
196
+ export function getAgentServerWorkingDir(): string {
197
+ const envDir = import.meta.env.VITE_WORKING_DIR?.trim();
198
+ if (envDir) return envDir;
199
+
200
+ return DEFAULT_WORKING_DIR;
201
+ }
202
+
203
+ function buildWorkingDir(base: string, conversationId: string): string {
204
+ const trimmed = base.replace(/\/+$/, "");
205
+ const hex = conversationId.replace(/-/g, "");
206
+ return `${trimmed}/${hex}`;
207
+ }
208
+
209
+ export function buildConversationWorkingDir(conversationId: string): string {
210
+ return buildWorkingDir(getAgentServerWorkingDir(), conversationId);
211
+ }
212
+
213
+ /**
214
+ * Conversation working dir under the backend-relative default
215
+ * (`workspace/project/<hex>`), deliberately ignoring any baked absolute
216
+ * `VITE_WORKING_DIR`. `resolveAbsoluteAgentServerPath()` anchors this to the
217
+ * active backend's own home via `GET /api/file/home`, so it resolves to a
218
+ * writable path on whichever backend actually runs the conversation.
219
+ */
220
+ export function buildRelativeConversationWorkingDir(
221
+ conversationId: string,
222
+ ): string {
223
+ return buildWorkingDir(DEFAULT_WORKING_DIR, conversationId);
224
+ }
225
+
226
+ /**
227
+ * Whether `backendHost` is the same host that served this frontend.
228
+ *
229
+ * A launcher-baked `VITE_WORKING_DIR` is an absolute path on the serving
230
+ * host's filesystem, so it is only valid on that exact backend. The seeded
231
+ * `default-local` entry starts life pointing at the served origin, but its
232
+ * `host` is mutable — the user can edit it to a remote backend while its id
233
+ * stays `default-local`. Matching on the host (not the stable id) keeps the
234
+ * real invariant: the baked path is only safe for the host it was baked for.
235
+ */
236
+ export function isServedOriginHost(
237
+ backendHost: string | null | undefined,
238
+ ): boolean {
239
+ const served = normalizeBaseUrl(getAgentServerBaseUrl());
240
+ const candidate = normalizeBaseUrl(backendHost);
241
+ if (!served || !candidate) return false;
242
+ return served === candidate;
243
+ }
244
+
245
+ /**
246
+ * Base working dir for a new conversation on the backend at `backendHost`.
247
+ * The baked (possibly absolute) default is used only for the served-origin
248
+ * backend; every other backend gets the relative default, anchored to its
249
+ * own home by `resolveAbsoluteAgentServerPath()`.
250
+ */
251
+ export function buildConversationWorkingDirForBackend(
252
+ conversationId: string,
253
+ backendHost: string | null | undefined,
254
+ ): string {
255
+ return isServedOriginHost(backendHost)
256
+ ? buildConversationWorkingDir(conversationId)
257
+ : buildRelativeConversationWorkingDir(conversationId);
258
+ }
259
+
260
+ /**
261
+ * Workspace root for `backendHost` — the dir each conversation's `<root>/<hex>`
262
+ * working dir is created under. Same per-backend rule as
263
+ * `buildConversationWorkingDirForBackend()`.
264
+ */
265
+ export function getWorkspaceRootForBackend(
266
+ backendHost: string | null | undefined,
267
+ ): string {
268
+ return isServedOriginHost(backendHost)
269
+ ? getAgentServerWorkingDir()
270
+ : DEFAULT_WORKING_DIR;
271
+ }
272
+
273
+ export function getAgentServerHeaders(): Record<string, string> {
274
+ const sessionApiKey = getAgentServerSessionApiKey();
275
+ return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {};
276
+ }
277
+
278
+ export function isAuthRequired(): boolean {
279
+ return (
280
+ import.meta.env.VITE_AUTH_REQUIRED === "true" ||
281
+ (typeof window !== "undefined" &&
282
+ (window as unknown as Record<string, unknown>)
283
+ .__AGENT_CANVAS_AUTH_REQUIRED__ === true)
284
+ );
285
+ }
286
+
287
+ export function isAuthRequiredAndMissing(): boolean {
288
+ if (!isAuthRequired()) return false;
289
+ return !getAgentServerSessionApiKey();
290
+ }
src/api/agent-server-home.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @spec WUP-001 — Resolve relative working dirs against /api/file/home
2
+ import { FileClient } from "@openhands/typescript-client/clients";
3
+ import {
4
+ getAgentServerClientOptions,
5
+ type AgentServerClientOverrides,
6
+ } from "./agent-server-client-options";
7
+
8
+ /**
9
+ * Cache the agent-server's home directory per host so we only round-trip
10
+ * `/api/file/home` once per backend. The home dir is effectively static for
11
+ * the lifetime of a running agent-server (it's `Path.home()` on the host),
12
+ * so caching is safe and avoids hammering the endpoint on every upload.
13
+ *
14
+ * The cache holds `Promise<string>` rather than `string` so concurrent
15
+ * callers share a single in-flight request.
16
+ */
17
+ const homeDirCache = new Map<string, Promise<string>>();
18
+
19
+ function isAbsolutePath(path: string): boolean {
20
+ // Treat POSIX-style and Windows-style absolute paths as absolute.
21
+ // The agent-server itself is POSIX on Linux/macOS and uses `\\` style
22
+ // on Windows; we just need to know whether `Path(path).is_absolute()`
23
+ // would return true on the server.
24
+ //
25
+ // Patterns covered:
26
+ // POSIX absolute: /foo/bar
27
+ // Windows drive: C:\foo or C:/foo
28
+ // Windows UNC: \\server\share (starts with `\`, matches `[/\\]`)
29
+ return /^([/\\]|[a-zA-Z]:[/\\])/.test(path);
30
+ }
31
+
32
+ /**
33
+ * Join a parent directory and a relative child segment with a forward slash,
34
+ * collapsing any duplicate separators that result from the join.
35
+ */
36
+ function joinPath(parent: string, child: string): string {
37
+ const left = parent.replace(/[/\\]+$/, "");
38
+ const right = child.replace(/^[/\\]+/, "");
39
+ return `${left}/${right}`;
40
+ }
41
+
42
+ /**
43
+ * Fetch and cache the agent-server's home directory via `GET /api/file/home`.
44
+ *
45
+ * The result is the absolute path returned by `Path.home()` on the
46
+ * agent-server host (e.g. `/Users/foo`, `/root`, or `C:\\Users\\Foo`). This
47
+ * is the most reliable absolute, writable anchor the agent-server API
48
+ * currently exposes — `/server_info` doesn't include the process CWD.
49
+ *
50
+ * @param overrides Same shape as `getAgentServerClientOptions` — lets cloud
51
+ * sandboxes pass a `conversationUrl` + `sessionApiKey` so the lookup goes
52
+ * to the per-conversation runtime rather than the bundled local backend.
53
+ */
54
+ export async function getAgentServerHomeDir(
55
+ overrides: AgentServerClientOverrides = {},
56
+ ): Promise<string> {
57
+ const options = getAgentServerClientOptions(overrides);
58
+ const cacheKey = options.host;
59
+ const cached = homeDirCache.get(cacheKey);
60
+ if (cached) return cached;
61
+
62
+ const lookup = (async () => {
63
+ const { home } = await new FileClient(options).getHome();
64
+ if (!home || typeof home !== "string") {
65
+ throw new Error("Agent server returned an empty home directory");
66
+ }
67
+ return home.replace(/[/\\]+$/, "");
68
+ })();
69
+
70
+ homeDirCache.set(cacheKey, lookup);
71
+ try {
72
+ return await lookup;
73
+ } catch (error) {
74
+ // Don't cache failures — let the next call retry.
75
+ homeDirCache.delete(cacheKey);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ /** Test-only helper. */
81
+ export function clearAgentServerHomeDirCache(): void {
82
+ homeDirCache.clear();
83
+ }
84
+
85
+ /**
86
+ * Resolve a (possibly relative) working dir to an absolute path the
87
+ * agent-server's file APIs will accept.
88
+ *
89
+ * - If `workingDir` is already absolute, returns it unchanged.
90
+ * - Otherwise prepends the agent-server's home dir (looked up via
91
+ * `/api/file/home` and cached). This matches how the published binary
92
+ * and Docker entrypoint expect to anchor relative working dirs: under
93
+ * `~/workspace/project` rather than the filesystem root.
94
+ *
95
+ * Why this matters: the agent-server's `/api/file/upload` endpoint requires
96
+ * an absolute path and `mkdir -p`s the parent. Naively prepending `/` to a
97
+ * relative dir like `workspace/project/<hex>` produces `/workspace/...`,
98
+ * which on macOS lives under the SIP-protected read-only root and fails
99
+ * with `Errno 30: Read-only file system: '/workspace'`. Resolving against
100
+ * `Path.home()` instead puts the path somewhere reliably writable.
101
+ */
102
+ export async function resolveAbsoluteAgentServerPath(
103
+ workingDir: string,
104
+ overrides: AgentServerClientOverrides = {},
105
+ ): Promise<string> {
106
+ const trimmed = workingDir.replace(/[/\\]+$/, "");
107
+ if (!trimmed) {
108
+ return getAgentServerHomeDir(overrides);
109
+ }
110
+ if (isAbsolutePath(trimmed)) return trimmed;
111
+
112
+ const home = await getAgentServerHomeDir(overrides);
113
+ return joinPath(home, trimmed);
114
+ }
src/api/canvas-extensions-service.test.ts ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import CanvasExtensionsService, {
4
+ CanvasExtensionsUnsupportedError,
5
+ } from "#/api/canvas-extensions-service";
6
+ import {
7
+ __resetActiveStoreForTests,
8
+ setActiveSelection,
9
+ setRegisteredBackends,
10
+ } from "#/api/backend-registry/active-store";
11
+ import type { Backend } from "#/api/backend-registry/types";
12
+ import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
13
+
14
+ vi.mock("@openhands/typescript-client/clients", () => ({
15
+ AgentServerClient: vi.fn(),
16
+ }));
17
+
18
+ const get = vi.fn();
19
+ const post = vi.fn();
20
+ const patch = vi.fn();
21
+ const remove = vi.fn();
22
+ const request = vi.fn();
23
+
24
+ const localBackend: Backend = {
25
+ id: "local",
26
+ name: "Local",
27
+ host: "http://127.0.0.1:8000",
28
+ apiKey: "session-key",
29
+ kind: "local",
30
+ };
31
+
32
+ const extension: InstalledCanvasExtensionInfo = {
33
+ name: "demo-extension",
34
+ version: "0.1.0",
35
+ description: "Demo",
36
+ enabled: false,
37
+ source: "github:example/extensions",
38
+ resolved_ref: "abc123",
39
+ repo_path: "extensions/demo",
40
+ installed_at: "2026-08-01T00:00:00Z",
41
+ install_path: "/tmp/extensions/demo-extension",
42
+ manifest: {
43
+ schema_version: 1,
44
+ name: "demo-extension",
45
+ version: "0.1.0",
46
+ entrypoint: "dist/extension.js",
47
+ contributes: {
48
+ pages: [{ id: "home", title: "Home", path: "home" }],
49
+ },
50
+ },
51
+ };
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ __resetActiveStoreForTests();
56
+ setRegisteredBackends([localBackend]);
57
+ setActiveSelection({ backendId: localBackend.id });
58
+ vi.mocked(AgentServerClient).mockImplementation(
59
+ function MockAgentServerClient() {
60
+ return {
61
+ get,
62
+ post,
63
+ patch,
64
+ delete: remove,
65
+ request,
66
+ close: vi.fn(),
67
+ } as unknown as AgentServerClient;
68
+ } as unknown as typeof AgentServerClient,
69
+ );
70
+ });
71
+
72
+ afterEach(() => {
73
+ setActiveSelection(null);
74
+ setRegisteredBackends([]);
75
+ __resetActiveStoreForTests();
76
+ });
77
+
78
+ describe("CanvasExtensionsService", () => {
79
+ it("lists extensions installed on the active Agent Server", async () => {
80
+ get.mockResolvedValue({ canvas_extensions: [extension] });
81
+
82
+ await expect(CanvasExtensionsService.listInstalled()).resolves.toEqual([
83
+ extension,
84
+ ]);
85
+ expect(get).toHaveBeenCalledWith("/api/canvas-extensions/installed");
86
+ });
87
+
88
+ it("preserves install coordinates without allowing an enabled flag", async () => {
89
+ post.mockResolvedValue(extension);
90
+
91
+ await CanvasExtensionsService.install({
92
+ source: "github:example/extensions",
93
+ ref: "main",
94
+ repo_path: "extensions/demo",
95
+ });
96
+
97
+ expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
98
+ source: "github:example/extensions",
99
+ ref: "main",
100
+ repo_path: "extensions/demo",
101
+ });
102
+ });
103
+
104
+ it.each([
105
+ {
106
+ source: "/repository-name/",
107
+ repoPath: "/demo-extension",
108
+ expectedSource: "/repository-name/demo-extension",
109
+ },
110
+ {
111
+ source: "/repository-name",
112
+ repoPath: "demo-extension",
113
+ expectedSource: "/repository-name/demo-extension",
114
+ },
115
+ {
116
+ source: "/repository-name/demo-extension",
117
+ repoPath: null,
118
+ expectedSource: "/repository-name/demo-extension",
119
+ },
120
+ ])(
121
+ "resolves a local extension from source $source and path $repoPath",
122
+ async ({ source, repoPath, expectedSource }) => {
123
+ post.mockResolvedValue(extension);
124
+
125
+ await CanvasExtensionsService.install({
126
+ source,
127
+ ref: null,
128
+ repo_path: repoPath,
129
+ });
130
+
131
+ expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
132
+ source: expectedSource,
133
+ ref: null,
134
+ repo_path: null,
135
+ });
136
+ },
137
+ );
138
+
139
+ it("maps a missing router to an explicit unsupported-backend error", async () => {
140
+ get.mockRejectedValue(
141
+ Object.assign(new Error("Not Found"), {
142
+ name: "HttpError",
143
+ status: 404,
144
+ }),
145
+ );
146
+
147
+ await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
148
+ expect.objectContaining<Partial<CanvasExtensionsUnsupportedError>>({
149
+ name: "CanvasExtensionsUnsupportedError",
150
+ reason: "missing-api",
151
+ }),
152
+ );
153
+ });
154
+
155
+ it("does not call the Agent Server protocol for a Cloud backend", async () => {
156
+ const cloud: Backend = {
157
+ ...localBackend,
158
+ id: "cloud",
159
+ name: "Cloud",
160
+ kind: "cloud",
161
+ };
162
+ setRegisteredBackends([cloud]);
163
+ setActiveSelection({ backendId: cloud.id });
164
+
165
+ await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
166
+ expect.objectContaining({ reason: "cloud-backend" }),
167
+ );
168
+ expect(AgentServerClient).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("fetches the bundle as authenticated text from the captured backend", async () => {
172
+ get.mockResolvedValue("export const activate = () => {};");
173
+
174
+ await expect(
175
+ CanvasExtensionsService.fetchBundle(extension.name, localBackend),
176
+ ).resolves.toContain("activate");
177
+
178
+ expect(AgentServerClient).toHaveBeenCalledWith({
179
+ host: localBackend.host,
180
+ apiKey: localBackend.apiKey,
181
+ timeout: 60000,
182
+ });
183
+ expect(get).toHaveBeenCalledWith(
184
+ "/api/canvas-extensions/installed/demo-extension/bundle",
185
+ { responseType: "text" },
186
+ );
187
+ });
188
+
189
+ it("encodes extension names in mutation paths", async () => {
190
+ patch.mockResolvedValue({ name: "a/b", enabled: true });
191
+
192
+ await CanvasExtensionsService.setEnabled("a/b", true);
193
+
194
+ expect(patch).toHaveBeenCalledWith(
195
+ "/api/canvas-extensions/installed/a%2Fb",
196
+ { enabled: true },
197
+ );
198
+ });
199
+
200
+ it("does not send the backend session key to an absolute request URL", async () => {
201
+ await expect(
202
+ CanvasExtensionsService.requestAgentServer({
203
+ path: "https://example.com/collect",
204
+ }),
205
+ ).rejects.toThrow("root-relative path");
206
+ expect(AgentServerClient).not.toHaveBeenCalled();
207
+ });
208
+ });
src/api/canvas-extensions-service.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
2
+ import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
3
+ import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
4
+ import {
5
+ getActiveBackend,
6
+ isNoBackend,
7
+ } from "#/api/backend-registry/active-store";
8
+ import type { Backend } from "#/api/backend-registry/types";
9
+ import type {
10
+ CanvasExtensionAgentServerRequest,
11
+ InstallCanvasExtensionRequest,
12
+ InstalledCanvasExtensionInfo,
13
+ } from "#/types/canvas-extension";
14
+
15
+ const CANVAS_EXTENSIONS_BASE_PATH = "/api/canvas-extensions";
16
+ const REMOTE_EXTENSION_SOURCE_PATTERN =
17
+ /^(?:github:|https?:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/i;
18
+
19
+ export type CanvasExtensionsUnsupportedReason =
20
+ | "no-backend"
21
+ | "cloud-backend"
22
+ | "missing-api";
23
+
24
+ export class CanvasExtensionsUnsupportedError extends Error {
25
+ readonly reason: CanvasExtensionsUnsupportedReason;
26
+
27
+ constructor(reason: CanvasExtensionsUnsupportedReason) {
28
+ const message =
29
+ reason === "no-backend"
30
+ ? "Add an Agent Server backend to use Apps."
31
+ : reason === "cloud-backend"
32
+ ? "Apps are not available on Cloud backends yet."
33
+ : "This Agent Server does not support Apps yet. Upgrade the backend and try again.";
34
+ super(message);
35
+ this.name = "CanvasExtensionsUnsupportedError";
36
+ this.reason = reason;
37
+ }
38
+ }
39
+
40
+ export function isCanvasExtensionsUnsupportedError(
41
+ error: unknown,
42
+ ): error is CanvasExtensionsUnsupportedError {
43
+ return (
44
+ error instanceof CanvasExtensionsUnsupportedError ||
45
+ (typeof error === "object" &&
46
+ error !== null &&
47
+ "name" in error &&
48
+ error.name === "CanvasExtensionsUnsupportedError")
49
+ );
50
+ }
51
+
52
+ function requireSupportedBackend(): void {
53
+ const { backend } = getActiveBackend();
54
+ if (isNoBackend(backend)) {
55
+ throw new CanvasExtensionsUnsupportedError("no-backend");
56
+ }
57
+ if (backend.kind === "cloud") {
58
+ throw new CanvasExtensionsUnsupportedError("cloud-backend");
59
+ }
60
+ }
61
+
62
+ function getClient(): AgentServerClient {
63
+ requireSupportedBackend();
64
+ return new AgentServerClient(getAgentServerClientOptions());
65
+ }
66
+
67
+ function getClientForBackend(backend: Backend): AgentServerClient {
68
+ if (isNoBackend(backend)) {
69
+ throw new CanvasExtensionsUnsupportedError("no-backend");
70
+ }
71
+ if (backend.kind === "cloud") {
72
+ throw new CanvasExtensionsUnsupportedError("cloud-backend");
73
+ }
74
+ return new AgentServerClient({
75
+ host: backend.host,
76
+ ...(backend.apiKey ? { apiKey: backend.apiKey } : {}),
77
+ timeout: 60000,
78
+ });
79
+ }
80
+
81
+ async function mapUnsupported<T>(operation: () => Promise<T>): Promise<T> {
82
+ try {
83
+ return await operation();
84
+ } catch (error) {
85
+ if (isSdkHttpStatusError(error, 404)) {
86
+ throw new CanvasExtensionsUnsupportedError("missing-api");
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ function installedExtensionPath(name: string): string {
93
+ return `${CANVAS_EXTENSIONS_BASE_PATH}/installed/${encodeURIComponent(name)}`;
94
+ }
95
+
96
+ function joinLocalExtensionPath(source: string, repoPath: string): string {
97
+ const separator = source.includes("\\") && !source.includes("/") ? "\\" : "/";
98
+ const basePath = source.replace(/[\\/]+$/, "");
99
+ const childPath = repoPath.replace(/^[\\/]+/, "");
100
+
101
+ if (!childPath) return source;
102
+ if (!basePath) return `${separator}${childPath}`;
103
+ return `${basePath}${separator}${childPath}`;
104
+ }
105
+
106
+ function normalizeInstallRequest(
107
+ request: InstallCanvasExtensionRequest,
108
+ ): InstallCanvasExtensionRequest {
109
+ const repoPath = request.repo_path?.trim();
110
+ if (!repoPath || REMOTE_EXTENSION_SOURCE_PATTERN.test(request.source)) {
111
+ return request;
112
+ }
113
+
114
+ return {
115
+ ...request,
116
+ source: joinLocalExtensionPath(request.source, repoPath),
117
+ repo_path: null,
118
+ };
119
+ }
120
+
121
+ class CanvasExtensionsService {
122
+ static async listInstalled(): Promise<InstalledCanvasExtensionInfo[]> {
123
+ const client = getClient();
124
+ const response = await mapUnsupported(() =>
125
+ client.get<{ canvas_extensions: InstalledCanvasExtensionInfo[] }>(
126
+ `${CANVAS_EXTENSIONS_BASE_PATH}/installed`,
127
+ ),
128
+ );
129
+ return response.canvas_extensions ?? [];
130
+ }
131
+
132
+ static async install(
133
+ request: InstallCanvasExtensionRequest,
134
+ ): Promise<InstalledCanvasExtensionInfo> {
135
+ const client = getClient();
136
+ return mapUnsupported(() =>
137
+ client.post<InstalledCanvasExtensionInfo>(
138
+ `${CANVAS_EXTENSIONS_BASE_PATH}/install`,
139
+ normalizeInstallRequest(request),
140
+ ),
141
+ );
142
+ }
143
+
144
+ static async setEnabled(
145
+ name: string,
146
+ enabled: boolean,
147
+ ): Promise<{ name: string; enabled: boolean }> {
148
+ const client = getClient();
149
+ return mapUnsupported(() =>
150
+ client.patch<{ name: string; enabled: boolean }>(
151
+ installedExtensionPath(name),
152
+ { enabled },
153
+ ),
154
+ );
155
+ }
156
+
157
+ static async uninstall(name: string): Promise<{ message: string }> {
158
+ const client = getClient();
159
+ return mapUnsupported(() =>
160
+ client.delete<{ message: string }>(installedExtensionPath(name)),
161
+ );
162
+ }
163
+
164
+ static async fetchBundle(name: string, backend?: Backend): Promise<string> {
165
+ const client = backend ? getClientForBackend(backend) : getClient();
166
+ return mapUnsupported(() =>
167
+ client.get<string>(`${installedExtensionPath(name)}/bundle`, {
168
+ responseType: "text",
169
+ }),
170
+ );
171
+ }
172
+
173
+ static async requestAgentServer<T = unknown>(
174
+ request: CanvasExtensionAgentServerRequest,
175
+ backend?: Backend,
176
+ ): Promise<T> {
177
+ if (!request.path.startsWith("/") || request.path.startsWith("//")) {
178
+ throw new Error(
179
+ "Canvas Extension Agent Server requests must use a root-relative path.",
180
+ );
181
+ }
182
+ const client = backend ? getClientForBackend(backend) : getClient();
183
+ return client.request<T>({
184
+ method: request.method ?? "GET",
185
+ path: request.path,
186
+ body: request.body,
187
+ headers: request.headers,
188
+ });
189
+ }
190
+ }
191
+
192
+ export default CanvasExtensionsService;
src/api/canvas-ui-client-tool.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
2
+
3
+ export {
4
+ CANVAS_UI_CLIENT_ACTION_KIND,
5
+ CANVAS_UI_CLIENT_TOOL_NAME,
6
+ LEGACY_CANVAS_UI_TOOL_NAME,
7
+ } from "#/constants/canvas-ui";
8
+
9
+ export interface ClientToolSpec {
10
+ name: string;
11
+ description: string;
12
+ parameters: Record<string, unknown>;
13
+ annotations?: {
14
+ title?: string | null;
15
+ readOnlyHint: boolean;
16
+ destructiveHint: boolean;
17
+ idempotentHint: boolean;
18
+ openWorldHint: boolean;
19
+ };
20
+ }
21
+
22
+ const CANVAS_UI_DESCRIPTION = `The user is interacting with you inside Agent Canvas — a web UI with a chat panel on the left and a tabbed right-side panel (files, terminal, browser, vscode, planner, tasklist). This tool lets you drive that right-side panel so the user sees what you just produced.
23
+
24
+ They will NOT see the files you wrote, the terminal output, or the browser
25
+ unless you call this tool to switch the right-side panel to the relevant
26
+ tab. Call this every time you finish work that produces something the user
27
+ should look at — don't rely on them noticing on their own.
28
+
29
+ When to call (pick the most specific option that matches your last action):
30
+
31
+ * You wrote or modified a single file (ANY language, ANY size — including
32
+ small scripts like a hello-world bash file) →
33
+ command="navigate_to_file", path=<workspace-relative path of that file>
34
+
35
+ * You generated an HTML page, image, SVG, PDF, markdown report, or other
36
+ previewable artifact →
37
+ command="show_preview", path=<that file>
38
+
39
+ * You finished editing multiple files in one logical step →
40
+ command="open_tab", tab="files"
41
+ (The Files tab automatically renders a diff view when the workspace has
42
+ uncommitted git changes, which covers the "highlight changes" case.)
43
+
44
+ * You ran a long-running terminal command, or one whose output the user
45
+ should inspect →
46
+ command="open_tab", tab="terminal"
47
+
48
+ * You browsed to a URL the user should see →
49
+ First call browser_get_state(include_screenshot=true) after your final
50
+ browser interaction so Agent Canvas has a screenshot to display, then call
51
+ command="open_tab", tab="browser"
52
+ (browser_navigate alone only updates the URL; without browser_get_state,
53
+ the Browser tab will open without a screenshot.)
54
+
55
+ Call this BEFORE writing your chat-message summary of the change, so the
56
+ artifact is visible while the user reads what you did. One canvas_ui_control
57
+ call per logical step is enough — don't repeat it for the same file or tab in
58
+ the same turn.`;
59
+
60
+ export const CANVAS_UI_CLIENT_TOOL: ClientToolSpec = {
61
+ name: CANVAS_UI_CLIENT_TOOL_NAME,
62
+ description: CANVAS_UI_DESCRIPTION,
63
+ parameters: {
64
+ type: "object",
65
+ additionalProperties: false,
66
+ properties: {
67
+ command: {
68
+ type: "string",
69
+ enum: ["navigate_to_file", "open_tab", "show_preview"],
70
+ description: "UI command to dispatch.",
71
+ },
72
+ path: {
73
+ type: "string",
74
+ description:
75
+ "Workspace-relative file path. Required for navigate_to_file and show_preview; ignored otherwise.",
76
+ },
77
+ tab: {
78
+ type: "string",
79
+ enum: ["files", "browser", "vscode", "terminal", "planner", "tasklist"],
80
+ description: "Tab to open. Required for open_tab; ignored otherwise.",
81
+ },
82
+ },
83
+ required: ["command"],
84
+ },
85
+ annotations: {
86
+ readOnlyHint: true,
87
+ destructiveHint: false,
88
+ idempotentHint: true,
89
+ openWorldHint: false,
90
+ },
91
+ };
src/api/client-source.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import packageJson from "../../package.json";
2
+
3
+ export const AGENT_CANVAS_CLIENT_SOURCE = "agent_canvas";
4
+ export const AGENT_CANVAS_CLIENT_VERSION = packageJson.version;
5
+
6
+ /**
7
+ * Coarse, non-user-identifying request metadata for Cloud observability.
8
+ *
9
+ * Cloud ingress can retain these headers as facets without logging request
10
+ * bodies, API keys, device codes, or conversation content.
11
+ */
12
+ export const OPENHANDS_CLIENT_HEADER = "X-OpenHands-Client";
13
+ export const OPENHANDS_CLIENT_VERSION_HEADER = "X-OpenHands-Client-Version";
14
+ export const OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER =
15
+ "X-OpenHands-Telemetry-Distinct-Id";
16
+
17
+ export const AGENT_CANVAS_CLIENT_HEADERS: Readonly<Record<string, string>> = {
18
+ [OPENHANDS_CLIENT_HEADER]: AGENT_CANVAS_CLIENT_SOURCE,
19
+ [OPENHANDS_CLIENT_VERSION_HEADER]: AGENT_CANVAS_CLIENT_VERSION,
20
+ };
src/api/conversation-file-upload.api.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
2
+ import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
3
+ import { getActiveBackend } from "#/api/backend-registry/active-store";
4
+ import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api";
5
+ import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
6
+ import type { FileUploadSuccessResponse } from "#/api/open-hands.types";
7
+ import {
8
+ buildWorkspaceUploadPath,
9
+ getSafeUploadFileName,
10
+ resolveConversationUploadWorkingDir,
11
+ } from "#/api/workspace-upload-path";
12
+
13
+ const FILE_UPLOAD_CONCURRENCY = 5;
14
+
15
+ export interface ConversationRuntimeContext {
16
+ conversationUrl: string | null;
17
+ sessionApiKey: string | null;
18
+ }
19
+
20
+ /**
21
+ * Resolve the sandbox runtime URL + session key needed for file upload and
22
+ * send-event calls. Cloud conversations only exist on the provisioned runtime,
23
+ * not on the bundled local agent-server.
24
+ */
25
+ export async function resolveConversationRuntime(
26
+ conversationId: string,
27
+ currentConversation?: AppConversation | null,
28
+ ): Promise<ConversationRuntimeContext> {
29
+ if (
30
+ currentConversation?.id === conversationId &&
31
+ currentConversation.conversation_url?.trim() &&
32
+ currentConversation.session_api_key?.trim()
33
+ ) {
34
+ return {
35
+ conversationUrl: currentConversation.conversation_url.trim(),
36
+ sessionApiKey: currentConversation.session_api_key.trim(),
37
+ };
38
+ }
39
+
40
+ if (getActiveBackend().backend.kind === "cloud") {
41
+ const [conversation] = await batchGetCloudConversations([conversationId]);
42
+ return {
43
+ conversationUrl: conversation?.conversation_url?.trim() ?? null,
44
+ sessionApiKey: conversation?.session_api_key?.trim() ?? null,
45
+ };
46
+ }
47
+
48
+ return { conversationUrl: null, sessionApiKey: null };
49
+ }
50
+
51
+ function requireCloudRuntime(
52
+ runtime: ConversationRuntimeContext,
53
+ ): ConversationRuntimeContext & {
54
+ conversationUrl: string;
55
+ sessionApiKey: string;
56
+ } {
57
+ if (!runtime.conversationUrl || !runtime.sessionApiKey) {
58
+ throw new Error(
59
+ "Conversation sandbox is still starting. Wait for it to finish, then try again.",
60
+ );
61
+ }
62
+ return {
63
+ conversationUrl: runtime.conversationUrl,
64
+ sessionApiKey: runtime.sessionApiKey,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Upload attachments into the conversation workspace. Local conversations use
70
+ * the bundled agent-server; cloud conversations target the provisioned runtime.
71
+ */
72
+ export async function uploadFilesToConversation(
73
+ conversationId: string,
74
+ files: File[],
75
+ currentConversation?: AppConversation | null,
76
+ ): Promise<FileUploadSuccessResponse> {
77
+ const workingDir = await resolveConversationUploadWorkingDir(
78
+ conversationId,
79
+ currentConversation,
80
+ );
81
+ const runtime = await resolveConversationRuntime(
82
+ conversationId,
83
+ currentConversation,
84
+ );
85
+ const isCloud = getActiveBackend().backend.kind === "cloud";
86
+
87
+ const sessionApiKey =
88
+ currentConversation?.id === conversationId
89
+ ? (currentConversation.session_api_key ?? runtime.sessionApiKey)
90
+ : runtime.sessionApiKey;
91
+ const conversationUrl =
92
+ currentConversation?.id === conversationId
93
+ ? (currentConversation.conversation_url ?? runtime.conversationUrl)
94
+ : runtime.conversationUrl;
95
+
96
+ if (isCloud) {
97
+ const cloudRuntime = requireCloudRuntime({
98
+ conversationUrl,
99
+ sessionApiKey,
100
+ });
101
+ return uploadFilesToRuntime({
102
+ files,
103
+ workingDir,
104
+ conversationUrl: cloudRuntime.conversationUrl,
105
+ sessionApiKey: cloudRuntime.sessionApiKey,
106
+ });
107
+ }
108
+
109
+ return uploadFilesToRuntime({
110
+ files,
111
+ workingDir,
112
+ conversationUrl,
113
+ sessionApiKey,
114
+ });
115
+ }
116
+
117
+ async function uploadFilesToRuntime(options: {
118
+ files: File[];
119
+ workingDir: string;
120
+ conversationUrl: string | null;
121
+ sessionApiKey: string | null;
122
+ }): Promise<FileUploadSuccessResponse> {
123
+ const { files, workingDir, conversationUrl, sessionApiKey } = options;
124
+ const workspace = new RemoteWorkspace(
125
+ getAgentServerClientOptions({
126
+ conversationUrl,
127
+ sessionApiKey,
128
+ workingDir,
129
+ }),
130
+ );
131
+
132
+ const uploadFile = async (file: File) => {
133
+ try {
134
+ const safeName = getSafeUploadFileName(file.name);
135
+ // @spec WUP-001 — Build an absolute upload path that's anchored against
136
+ // the agent-server's home dir (when `workingDir` is relative) instead
137
+ // of the filesystem root. Without this, default conversations whose
138
+ // working_dir is `workspace/project/<hex>` (relative) land at
139
+ // `/workspace/project/<hex>/...` on the agent-server, which on macOS
140
+ // and fresh containers is a read-only mount.
141
+ const uploadPath = await buildWorkspaceUploadPath(file.name, workingDir, {
142
+ conversationUrl,
143
+ sessionApiKey,
144
+ });
145
+ await workspace.fileUpload(file, uploadPath);
146
+ return { uploadedFile: safeName, skippedFile: null };
147
+ } catch (error) {
148
+ return {
149
+ uploadedFile: null,
150
+ skippedFile: {
151
+ name: file.name,
152
+ reason: error instanceof Error ? error.message : "Upload failed",
153
+ },
154
+ };
155
+ }
156
+ };
157
+
158
+ const results: Awaited<ReturnType<typeof uploadFile>>[] = [];
159
+ for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) {
160
+ const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY);
161
+ results.push(...(await Promise.all(batch.map(uploadFile))));
162
+ }
163
+
164
+ return {
165
+ uploaded_files: results.flatMap((result) =>
166
+ result.uploadedFile ? [result.uploadedFile] : [],
167
+ ),
168
+ skipped_files: results.flatMap((result) =>
169
+ result.skippedFile ? [result.skippedFile] : [],
170
+ ),
171
+ };
172
+ }
src/api/conversation-metadata-store.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Provider } from "#/types/settings";
2
+ import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
3
+
4
+ const STORAGE_KEY = "openhands-agent-server-conversation-metadata";
5
+
6
+ export type WorkspaceMode = "local_repo" | "new_worktree";
7
+
8
+ export interface ConversationMetadata {
9
+ selected_repository: string | null;
10
+ selected_branch: string | null;
11
+ git_provider: Provider | null;
12
+ /**
13
+ * The local workspace path the user explicitly attached at conversation
14
+ * creation time. Distinct from `selected_repository` (which is set by
15
+ * the repo picker on the home page). Used by the Files tab to decide
16
+ * whether to default to diff view: if the user attached *anything*
17
+ * (repo or local workspace), we lean diff-first because there's a real
18
+ * git baseline to compare against.
19
+ */
20
+ selected_workspace?: string | null;
21
+ /**
22
+ * How the conversation should use `selected_workspace`.
23
+ *
24
+ * `local_repo` means the runtime should operate directly in the selected
25
+ * folder, even when it is not a git checkout. `new_worktree` preserves the
26
+ * historical agent-server behavior for conversations that should start in a
27
+ * generated per-conversation worktree.
28
+ */
29
+ workspace_mode?: WorkspaceMode | null;
30
+ /**
31
+ * The LLM profile the conversation was created with (or last switched to).
32
+ * Client-side only. Lets the chat-header switcher show the exact profile
33
+ * name even when several profiles share the same underlying model — the
34
+ * agent-server only round-trips the model string, so matching on it alone
35
+ * is ambiguous (issue #1082).
36
+ */
37
+ active_profile?: string | null;
38
+ /**
39
+ * When `active_profile` was stamped (ISO string). Written by the live
40
+ * WebSocket handler (the observation's server timestamp) and the user
41
+ * `/model` mutation (client clock). The history seed compares the latest
42
+ * SwitchLLM observation's timestamp against this so a reload can repair a
43
+ * missed agent switch without rolling back a newer manual switch — manual
44
+ * `/model` switches go through REST and leave no observation in history.
45
+ * Absent on stamps written before this field existed; treated as older
46
+ * than any observation so the seed can still repair them.
47
+ */
48
+ stamped_at?: string | null;
49
+ /** Store plugin coordinates only; parameters may contain secrets. */
50
+ plugins?: PluginSpec[] | null;
51
+ /**
52
+ * Local-only client-managed planning conversation id. Cloud backends expose
53
+ * real sub-conversations through `sub_conversation_ids`; local agent-server
54
+ * backends do not, so Canvas stores the relationship here.
55
+ */
56
+ local_planning_conversation_id?: string | null;
57
+ }
58
+
59
+ export const toPluginCoordinates = (plugin: PluginSpec): PluginSpec => ({
60
+ source: plugin.source,
61
+ ref: plugin.ref ?? null,
62
+ repo_path: plugin.repo_path ?? null,
63
+ });
64
+
65
+ type StoredMetadata = Record<string, ConversationMetadata>;
66
+
67
+ const readAll = (): StoredMetadata => {
68
+ if (typeof window === "undefined") return {};
69
+ try {
70
+ const raw = window.localStorage.getItem(STORAGE_KEY);
71
+ if (!raw) return {};
72
+ const parsed = JSON.parse(raw) as unknown;
73
+ if (!parsed || typeof parsed !== "object") return {};
74
+ return parsed as StoredMetadata;
75
+ } catch {
76
+ return {};
77
+ }
78
+ };
79
+
80
+ const writeAll = (next: StoredMetadata): void => {
81
+ if (typeof window === "undefined") return;
82
+ if (Object.keys(next).length === 0) {
83
+ window.localStorage.removeItem(STORAGE_KEY);
84
+ return;
85
+ }
86
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
87
+ };
88
+
89
+ export const getStoredConversationMetadata = (
90
+ conversationId: string,
91
+ ): ConversationMetadata | null => readAll()[conversationId] ?? null;
92
+
93
+ export const setStoredConversationMetadata = (
94
+ conversationId: string,
95
+ metadata: ConversationMetadata,
96
+ ): void => {
97
+ const all = readAll();
98
+ all[conversationId] = metadata;
99
+ writeAll(all);
100
+ };
101
+
102
+ /**
103
+ * Merge `patch` into a conversation's stored metadata, preserving every
104
+ * existing field — `setStoredConversationMetadata` does a full-object
105
+ * replace, which would otherwise silently drop fields the caller doesn't
106
+ * enumerate (e.g. `plugins`).
107
+ */
108
+ export const mergeStoredConversationMetadata = (
109
+ conversationId: string,
110
+ patch: Partial<ConversationMetadata>,
111
+ ): void => {
112
+ setStoredConversationMetadata(conversationId, {
113
+ selected_repository: null,
114
+ selected_branch: null,
115
+ git_provider: null,
116
+ ...(getStoredConversationMetadata(conversationId) ?? {}),
117
+ ...patch,
118
+ });
119
+ };
120
+
121
+ export const removeStoredConversationMetadata = (
122
+ conversationId: string,
123
+ ): void => {
124
+ const all = readAll();
125
+ if (!(conversationId in all)) return;
126
+ delete all[conversationId];
127
+ writeAll(all);
128
+ };
src/api/device-flow-client.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ pollForToken as sdkPollForToken,
3
+ startDeviceFlow as sdkStartDeviceFlow,
4
+ } from "@openhands/typescript-client/client/device-flow-client";
5
+ import type {
6
+ DeviceAuthorizationResponse,
7
+ DeviceTokenResponse,
8
+ PollDeviceTokenOptions,
9
+ } from "@openhands/typescript-client/client/device-flow-client";
10
+ import { AGENT_CANVAS_CLIENT_HEADERS } from "./client-source";
11
+
12
+ export {
13
+ DeviceFlowError,
14
+ isOpenHandsCloudHost,
15
+ } from "@openhands/typescript-client/client/device-flow-client";
16
+
17
+ export function startDeviceFlow(
18
+ host: string,
19
+ ): Promise<DeviceAuthorizationResponse> {
20
+ return sdkStartDeviceFlow(host, { headers: AGENT_CANVAS_CLIENT_HEADERS });
21
+ }
22
+
23
+ export function pollForToken(
24
+ host: string,
25
+ deviceCode: string,
26
+ options: PollDeviceTokenOptions,
27
+ ): Promise<DeviceTokenResponse> {
28
+ return sdkPollForToken(host, deviceCode, {
29
+ ...options,
30
+ headers: AGENT_CANVAS_CLIENT_HEADERS,
31
+ });
32
+ }
33
+
34
+ export type {
35
+ DeviceAuthorizationResponse,
36
+ DeviceTokenResponse,
37
+ PollDeviceTokenOptions as PollOptions,
38
+ } from "@openhands/typescript-client/client/device-flow-client";
src/api/git-provider-items-service.ts ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SettingsClient } from "@openhands/typescript-client/clients";
2
+ import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
3
+ import { SecretsService } from "#/api/secrets-service";
4
+ import { getActiveBackend } from "#/api/backend-registry/active-store";
5
+ import type { Provider } from "#/types/settings";
6
+ import { constructPullRequestUrl, getGitProviderBaseUrl } from "#/utils/utils";
7
+
8
+ export interface GitProviderItem {
9
+ id: number;
10
+ number: number;
11
+ title: string;
12
+ url: string;
13
+ authorLogin: string | null;
14
+ updatedAt: string | null;
15
+ }
16
+
17
+ const PROVIDER_TOKEN_SECRET_CANDIDATES: Partial<Record<Provider, string[]>> = {
18
+ github: ["GITHUB_TOKEN", "GH_TOKEN", "github"],
19
+ gitlab: ["GITLAB_TOKEN", "GL_TOKEN", "gitlab"],
20
+ bitbucket: ["BITBUCKET_TOKEN", "bitbucket"],
21
+ forgejo: ["FORGEJO_TOKEN", "forgejo"],
22
+ };
23
+
24
+ const LIST_LIMIT = 30;
25
+
26
+ async function resolveProviderToken(
27
+ provider: Provider,
28
+ ): Promise<string | null> {
29
+ // Cloud backends keep provider tokens server-side; browser-side secret
30
+ // lookup is only meaningful for the local agent-server secrets store.
31
+ if (getActiveBackend().backend.kind !== "local") {
32
+ return null;
33
+ }
34
+
35
+ const candidates = PROVIDER_TOKEN_SECRET_CANDIDATES[provider] ?? [];
36
+ if (candidates.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ const secrets = await SecretsService.getSecrets();
41
+ const available = new Set(secrets.map((secret) => secret.name));
42
+ const match = candidates.find((name) => available.has(name));
43
+ if (!match) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ return await new SettingsClient(getAgentServerClientOptions()).getSecret(
49
+ match,
50
+ );
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function constructIssueUrl(
57
+ issueNumber: number,
58
+ provider: Provider,
59
+ repositoryName: string,
60
+ ): string {
61
+ const baseUrl = getGitProviderBaseUrl(provider);
62
+ switch (provider) {
63
+ case "gitlab":
64
+ return `${baseUrl}/${repositoryName}/-/issues/${issueNumber}`;
65
+ case "bitbucket":
66
+ return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
67
+ case "forgejo":
68
+ return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
69
+ case "github":
70
+ default:
71
+ return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
72
+ }
73
+ }
74
+
75
+ function constructIssuesListUrl(
76
+ provider: Provider,
77
+ repositoryName: string,
78
+ ): string {
79
+ const baseUrl = getGitProviderBaseUrl(provider);
80
+ switch (provider) {
81
+ case "gitlab":
82
+ return `${baseUrl}/${repositoryName}/-/issues`;
83
+ case "bitbucket":
84
+ return `${baseUrl}/${repositoryName}/issues`;
85
+ case "forgejo":
86
+ return `${baseUrl}/${repositoryName}/issues`;
87
+ case "github":
88
+ default:
89
+ return `${baseUrl}/${repositoryName}/issues`;
90
+ }
91
+ }
92
+
93
+ function constructPullRequestsListUrl(
94
+ provider: Provider,
95
+ repositoryName: string,
96
+ ): string {
97
+ const baseUrl = getGitProviderBaseUrl(provider);
98
+ switch (provider) {
99
+ case "gitlab":
100
+ return `${baseUrl}/${repositoryName}/-/merge_requests`;
101
+ case "bitbucket":
102
+ return `${baseUrl}/${repositoryName}/pull-requests`;
103
+ case "forgejo":
104
+ return `${baseUrl}/${repositoryName}/pulls`;
105
+ case "github":
106
+ default:
107
+ return `${baseUrl}/${repositoryName}/pulls`;
108
+ }
109
+ }
110
+
111
+ async function fetchGithubJson<T>(
112
+ path: string,
113
+ token: string | null,
114
+ ): Promise<T> {
115
+ const headers: Record<string, string> = {
116
+ Accept: "application/vnd.github+json",
117
+ "X-GitHub-Api-Version": "2022-11-28",
118
+ };
119
+ if (token) {
120
+ headers.Authorization = `Bearer ${token}`;
121
+ }
122
+
123
+ const response = await fetch(`https://api.github.com${path}`, { headers });
124
+ if (!response.ok) {
125
+ throw new Error(`GitHub API ${response.status}`);
126
+ }
127
+ return response.json() as Promise<T>;
128
+ }
129
+
130
+ async function fetchGitlabJson<T>(
131
+ path: string,
132
+ token: string | null,
133
+ ): Promise<T> {
134
+ const headers: Record<string, string> = {
135
+ Accept: "application/json",
136
+ };
137
+ if (token) {
138
+ headers["PRIVATE-TOKEN"] = token;
139
+ }
140
+
141
+ // gitlab.com's public REST API — an external host, not the agent-server
142
+ // `/api` surface the rule guards (its `/api/v4` prefix just trips the
143
+ // substring heuristic; the companion no-direct-agent-server-calls test
144
+ // matches only relative `/api/...` URLs and is not affected).
145
+ // eslint-disable-next-line local/no-direct-agent-server-fetch
146
+ const response = await fetch(`https://gitlab.com/api/v4${path}`, { headers });
147
+ if (!response.ok) {
148
+ throw new Error(`GitLab API ${response.status}`);
149
+ }
150
+ return response.json() as Promise<T>;
151
+ }
152
+
153
+ type GithubIssueOrPr = {
154
+ id: number;
155
+ number: number;
156
+ title: string;
157
+ html_url: string;
158
+ user?: { login?: string } | null;
159
+ updated_at?: string | null;
160
+ pull_request?: unknown;
161
+ };
162
+
163
+ type GitlabMergeRequest = {
164
+ id: number;
165
+ iid: number;
166
+ title: string;
167
+ web_url: string;
168
+ author?: { username?: string } | null;
169
+ updated_at?: string | null;
170
+ };
171
+
172
+ type GitlabIssue = {
173
+ id: number;
174
+ iid: number;
175
+ title: string;
176
+ web_url: string;
177
+ author?: { username?: string } | null;
178
+ updated_at?: string | null;
179
+ };
180
+
181
+ /**
182
+ * Lists open pull requests / merge requests and issues for a connected
183
+ * repository by calling the provider's public REST API from the browser.
184
+ * When a matching token secret exists locally (e.g. `GITHUB_TOKEN`), it is
185
+ * used for auth so private repos work; otherwise public-repo unauthenticated
186
+ * requests are attempted.
187
+ */
188
+ export class GitProviderItemsService {
189
+ static constructIssuesListUrl = constructIssuesListUrl;
190
+
191
+ static constructPullRequestsListUrl = constructPullRequestsListUrl;
192
+
193
+ static async listPullRequests(
194
+ repository: string,
195
+ provider: Provider,
196
+ ): Promise<GitProviderItem[]> {
197
+ const token = await resolveProviderToken(provider);
198
+
199
+ if (provider === "gitlab") {
200
+ const encoded = encodeURIComponent(repository);
201
+ const items = await fetchGitlabJson<GitlabMergeRequest[]>(
202
+ `/projects/${encoded}/merge_requests?state=opened&per_page=${LIST_LIMIT}`,
203
+ token,
204
+ );
205
+ return items.map((item) => ({
206
+ id: item.id,
207
+ number: item.iid,
208
+ title: item.title,
209
+ url: item.web_url,
210
+ authorLogin: item.author?.username ?? null,
211
+ updatedAt: item.updated_at ?? null,
212
+ }));
213
+ }
214
+
215
+ if (provider !== "github" && provider !== "forgejo") {
216
+ return [];
217
+ }
218
+
219
+ const [owner, repo] = repository.split("/");
220
+ if (!owner || !repo) {
221
+ return [];
222
+ }
223
+
224
+ const items = await fetchGithubJson<GithubIssueOrPr[]>(
225
+ `/repos/${owner}/${repo}/pulls?state=open&per_page=${LIST_LIMIT}`,
226
+ token,
227
+ );
228
+
229
+ return items.map((item) => ({
230
+ id: item.id,
231
+ number: item.number,
232
+ title: item.title,
233
+ url:
234
+ item.html_url ||
235
+ constructPullRequestUrl(item.number, provider, repository),
236
+ authorLogin: item.user?.login ?? null,
237
+ updatedAt: item.updated_at ?? null,
238
+ }));
239
+ }
240
+
241
+ static async listIssues(
242
+ repository: string,
243
+ provider: Provider,
244
+ ): Promise<GitProviderItem[]> {
245
+ const token = await resolveProviderToken(provider);
246
+
247
+ if (provider === "gitlab") {
248
+ const encoded = encodeURIComponent(repository);
249
+ const items = await fetchGitlabJson<GitlabIssue[]>(
250
+ `/projects/${encoded}/issues?state=opened&per_page=${LIST_LIMIT}`,
251
+ token,
252
+ );
253
+ return items.map((item) => ({
254
+ id: item.id,
255
+ number: item.iid,
256
+ title: item.title,
257
+ url: item.web_url,
258
+ authorLogin: item.author?.username ?? null,
259
+ updatedAt: item.updated_at ?? null,
260
+ }));
261
+ }
262
+
263
+ if (provider !== "github" && provider !== "forgejo") {
264
+ return [];
265
+ }
266
+
267
+ const [owner, repo] = repository.split("/");
268
+ if (!owner || !repo) {
269
+ return [];
270
+ }
271
+
272
+ const items = await fetchGithubJson<GithubIssueOrPr[]>(
273
+ `/repos/${owner}/${repo}/issues?state=open&per_page=${LIST_LIMIT}`,
274
+ token,
275
+ );
276
+
277
+ // GitHub's issues endpoint also returns pull requests.
278
+ return items
279
+ .filter((item) => !item.pull_request)
280
+ .map((item) => ({
281
+ id: item.id,
282
+ number: item.number,
283
+ title: item.title,
284
+ url:
285
+ item.html_url || constructIssueUrl(item.number, provider, repository),
286
+ authorLogin: item.user?.login ?? null,
287
+ updatedAt: item.updated_at ?? null,
288
+ }));
289
+ }
290
+ }
src/api/hooks-service.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HooksClient } from "@openhands/typescript-client/clients";
2
+ import type { HookConfig } from "@openhands/typescript-client";
3
+ import { getAgentServerWorkingDir } from "./agent-server-config";
4
+ import { getEffectiveLocalBackend } from "./backend-registry/active-store";
5
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
6
+
7
+ // `loadHooks()` spends this twice (version probe, then the call) on the
8
+ // conversation-start critical path; the SDK's 60s default would stall a launch.
9
+ const HOOKS_LOAD_TIMEOUT_MS = 5000;
10
+
11
+ class HooksService {
12
+ /**
13
+ * Load workspace hooks from the agent-server by calling POST /api/hooks.
14
+ * Returns the HookConfig if the workspace has hooks, or null.
15
+ * Gracefully returns null when no usable local backend is available
16
+ * (cloud backend, unseeded registry, or older agent-server).
17
+ * `projectDir` must be the workspace root: the agent-server checks
18
+ * `<project_dir>/.openhands/hooks.json` literally and never walks parents.
19
+ */
20
+ static async loadWorkspaceHooks(
21
+ projectDir?: string,
22
+ ): Promise<HookConfig | null> {
23
+ // getEffectiveLocalBackend() returns null for cloud backends AND for the
24
+ // NO_BACKEND sentinel (unseeded/empty registry), avoiding the silent
25
+ // NoBackendAvailableError that getActiveBackend() would cause.
26
+ if (!getEffectiveLocalBackend()) {
27
+ return null;
28
+ }
29
+
30
+ try {
31
+ const response = await new HooksClient(
32
+ getAgentServerClientOptions({ timeout: HOOKS_LOAD_TIMEOUT_MS }),
33
+ ).loadHooks({
34
+ project_dir: projectDir ?? getAgentServerWorkingDir(),
35
+ });
36
+ return response?.hook_config ?? null;
37
+ } catch (error) {
38
+ // Agent-server may not support the hooks endpoint or may be
39
+ // unreachable; gracefully fall back to null.
40
+ console.warn(
41
+ "Failed to load workspace hooks, continuing without:",
42
+ error,
43
+ );
44
+ return null;
45
+ }
46
+ }
47
+ }
48
+
49
+ export default HooksService;
src/api/launch-child-conversation-client-tool.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ CHILD_CONVERSATION_ISOLATIONS,
3
+ CHILD_CONVERSATION_RESULT_PREFIX,
4
+ CHILD_CONVERSATION_TARGETS,
5
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
6
+ } from "#/constants/child-conversation";
7
+ import type { ClientToolSpec } from "./canvas-ui-client-tool";
8
+
9
+ export {
10
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
11
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
12
+ } from "#/constants/child-conversation";
13
+
14
+ const LAUNCH_CHILD_CONVERSATION_DESCRIPTION = `Start a NEW, independent conversation to work on a well-scoped task in parallel with this one, without the user having to run any CLI command. The new conversation is recorded as a child of this one.
15
+
16
+ The child runs on its own. It does not block you, you do not see its output,
17
+ and it cannot see this conversation's history — everything it needs must be in
18
+ the task brief.
19
+
20
+ Choosing target:
21
+
22
+ * target="local" — runs on the same machine, in this conversation's workspace.
23
+ Fast, no repository clone, no sandbox provisioning. Use this by default when
24
+ the work is on code that is already checked out here.
25
+
26
+ * target="cloud" — runs on OpenHands Cloud in its own isolated sandbox, from a
27
+ git repository. Use this when the work should not touch the user's machine or
28
+ when it needs a repository that is not checked out locally. Requires the user
29
+ to have an OpenHands Cloud backend connected in Agent Canvas; if none is
30
+ connected you will be told so and should fall back to target="local".
31
+
32
+ Writing the task brief:
33
+
34
+ * Put everything the child needs in "task": the goal, the relevant file paths,
35
+ the constraints, the expected deliverable, and how it should report back.
36
+ * Keep each child's scope independent of its siblings so parallel children do
37
+ not fight over the same files.
38
+ * One call per delegated task. Do NOT call this tool twice for the same task.
39
+
40
+ Parameter rules (a call that breaks one of these launches nothing and comes
41
+ back with corrective guidance):
42
+
43
+ * "repository" and "branch" apply to target="cloud" only. A local child always
44
+ inherits this conversation's workspace, so do not pass them with
45
+ target="local".
46
+ * "isolation" applies to target="local" only. Cloud sandboxes are always
47
+ isolated, so do not pass it with target="cloud".
48
+ * isolation="worktree" (the default) gives the child its own git worktree and
49
+ branch, cut from the repository's default branch — it will NOT see this
50
+ conversation's uncommitted or committed work. isolation="shared" puts the
51
+ child in this conversation's exact directory; only choose it when the child
52
+ genuinely must see work in progress, and expect the two agents to conflict.
53
+
54
+ What comes back: this tool is acknowledged immediately, before the conversation
55
+ exists. The real outcome arrives moments later as a follow-up message starting
56
+ with "${CHILD_CONVERSATION_RESULT_PREFIX.trim()}" that carries the child's
57
+ conversation id, URL, target and initial status — or an error with guidance.
58
+ Wait for it, then tell the user what you launched and give them the URL. If it
59
+ reports an error, fix the parameters and call this tool again.`;
60
+
61
+ export const LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL: ClientToolSpec = {
62
+ name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
63
+ description: LAUNCH_CHILD_CONVERSATION_DESCRIPTION,
64
+ parameters: {
65
+ type: "object",
66
+ additionalProperties: false,
67
+ properties: {
68
+ target: {
69
+ type: "string",
70
+ enum: [...CHILD_CONVERSATION_TARGETS],
71
+ description:
72
+ "Where the child runs. 'local' reuses this machine and this conversation's workspace; 'cloud' runs in an isolated OpenHands Cloud sandbox.",
73
+ },
74
+ task: {
75
+ type: "string",
76
+ description:
77
+ "Self-contained task brief, sent as the child's first message. The child cannot see this conversation, so state the goal, constraints, expected output and handoff criteria.",
78
+ },
79
+ title: {
80
+ type: "string",
81
+ description:
82
+ "Optional short title for the child conversation. Omit to let it title itself from the task.",
83
+ },
84
+ repository: {
85
+ type: "string",
86
+ description:
87
+ "target='cloud' only. Repository to clone into the sandbox, as 'owner/repo'. Defaults to this conversation's repository when it has one.",
88
+ },
89
+ branch: {
90
+ type: "string",
91
+ description:
92
+ "target='cloud' only, and only together with 'repository'. Defaults to the repository's default branch.",
93
+ },
94
+ isolation: {
95
+ type: "string",
96
+ enum: [...CHILD_CONVERSATION_ISOLATIONS],
97
+ description:
98
+ "target='local' only. 'worktree' (default) gives the child its own git worktree and branch; 'shared' runs it in this conversation's exact directory.",
99
+ },
100
+ },
101
+ required: ["target", "task"],
102
+ },
103
+ annotations: {
104
+ // Launching a conversation writes real state and is not repeatable, so the
105
+ // agent is asked to predict a security risk before every call.
106
+ readOnlyHint: false,
107
+ destructiveHint: false,
108
+ idempotentHint: false,
109
+ // The cloud target reaches OpenHands Cloud.
110
+ openWorldHint: true,
111
+ },
112
+ };
src/api/llm-balance-service.test.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ setActiveSelection,
4
+ setRegisteredBackends,
5
+ } from "#/api/backend-registry/active-store";
6
+ import type { Backend } from "#/api/backend-registry/types";
7
+ import {
8
+ LLM_BALANCE_PATH,
9
+ LLM_BALANCE_TIMEOUT_MS,
10
+ } from "#/constants/llm-balance";
11
+ import LLMBalanceService from "./llm-balance-service";
12
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
13
+
14
+ const getMock = vi.hoisted(() => vi.fn());
15
+
16
+ vi.mock("@openhands/typescript-client/clients", () => ({
17
+ AgentServerClient: vi.fn(function AgentServerClientMock() {
18
+ return { get: getMock, close: vi.fn() };
19
+ }),
20
+ }));
21
+
22
+ const localBackend: Backend = {
23
+ id: "local-test",
24
+ name: "Local test backend",
25
+ host: "http://localhost:3000",
26
+ apiKey: "test-session-key",
27
+ kind: "local",
28
+ };
29
+
30
+ const balancePayload = {
31
+ provider: "openrouter",
32
+ limit: 20,
33
+ limit_remaining: 14.96,
34
+ usage: 5.04,
35
+ usage_daily: 1.2,
36
+ usage_weekly: 3.4,
37
+ usage_monthly: 5.04,
38
+ is_free_tier: false,
39
+ };
40
+
41
+ function httpError(status: number): Error & { status: number } {
42
+ return Object.assign(new Error(`HTTP ${status}`), { status });
43
+ }
44
+
45
+ function mockedClient(): AgentServerClient {
46
+ return vi.mocked(AgentServerClient).mock.results[0]
47
+ ?.value as AgentServerClient;
48
+ }
49
+
50
+ describe("LLMBalanceService.getBalance", () => {
51
+ beforeEach(() => {
52
+ getMock.mockReset();
53
+ vi.mocked(AgentServerClient).mockClear();
54
+ setRegisteredBackends([localBackend]);
55
+ setActiveSelection({ backendId: localBackend.id });
56
+ });
57
+
58
+ afterEach(() => {
59
+ setActiveSelection(null);
60
+ setRegisteredBackends([]);
61
+ });
62
+
63
+ it("normalizes a successful balance response via the typed client", async () => {
64
+ getMock.mockResolvedValue(balancePayload);
65
+
66
+ const balance = await LLMBalanceService.getBalance();
67
+
68
+ expect(balance).toEqual({
69
+ provider: "openrouter",
70
+ limit: 20,
71
+ limitRemaining: 14.96,
72
+ usage: 5.04,
73
+ usageDaily: 1.2,
74
+ usageWeekly: 3.4,
75
+ usageMonthly: 5.04,
76
+ isFreeTier: false,
77
+ });
78
+
79
+ expect(vi.mocked(AgentServerClient)).toHaveBeenCalledWith({
80
+ host: "http://localhost:3000",
81
+ apiKey: "test-session-key",
82
+ timeout: LLM_BALANCE_TIMEOUT_MS,
83
+ });
84
+ expect(getMock).toHaveBeenCalledWith(LLM_BALANCE_PATH, {
85
+ acceptableStatusCodes: new Set([200]),
86
+ responseType: "json",
87
+ timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000),
88
+ });
89
+ expect(mockedClient().close).toHaveBeenCalled();
90
+ });
91
+
92
+ it("treats null caps as an uncapped key", async () => {
93
+ getMock.mockResolvedValue({
94
+ ...balancePayload,
95
+ limit: null,
96
+ limit_remaining: null,
97
+ });
98
+
99
+ const balance = await LLMBalanceService.getBalance();
100
+
101
+ expect(balance?.limit).toBeNull();
102
+ expect(balance?.limitRemaining).toBeNull();
103
+ expect(balance?.usage).toBe(5.04);
104
+ });
105
+
106
+ it("returns null when the endpoint is missing (404)", async () => {
107
+ getMock.mockRejectedValue(httpError(404));
108
+
109
+ await expect(LLMBalanceService.getBalance()).resolves.toBeNull();
110
+ });
111
+
112
+ it("returns null for a malformed response body", async () => {
113
+ getMock.mockResolvedValue({ unexpected: true });
114
+
115
+ await expect(LLMBalanceService.getBalance()).resolves.toBeNull();
116
+ });
117
+
118
+ it("throws on non-404 HTTP failures", async () => {
119
+ getMock.mockRejectedValue(httpError(500));
120
+
121
+ await expect(LLMBalanceService.getBalance()).rejects.toThrow(
122
+ "Balance request failed with 500",
123
+ );
124
+ });
125
+
126
+ it("throws a named error when the request times out", async () => {
127
+ getMock.mockRejectedValue(
128
+ new DOMException("The operation was aborted.", "TimeoutError"),
129
+ );
130
+
131
+ await expect(LLMBalanceService.getBalance()).rejects.toThrow(
132
+ "Balance request timed out",
133
+ );
134
+ });
135
+ });
src/api/llm-balance-service.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
2
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
3
+ import {
4
+ LLM_BALANCE_PATH,
5
+ LLM_BALANCE_TIMEOUT_MS,
6
+ } from "#/constants/llm-balance";
7
+
8
+ /**
9
+ * Credit balance reported by the agent server for the active LLM provider
10
+ * (currently only OpenRouter). Amounts are in USD credits.
11
+ */
12
+ export interface LLMBalance {
13
+ provider: string;
14
+ /** Credit cap configured for the key, or null when uncapped. */
15
+ limit: number | null;
16
+ /** Credits remaining under `limit`, or null when uncapped. */
17
+ limitRemaining: number | null;
18
+ /** Total credits consumed by the key. */
19
+ usage: number;
20
+ usageDaily: number | null;
21
+ usageWeekly: number | null;
22
+ usageMonthly: number | null;
23
+ isFreeTier: boolean;
24
+ }
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function numberOrNull(value: unknown): number | null {
31
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
32
+ }
33
+
34
+ /** `AbortSignal.timeout` rejects with a `TimeoutError`; a manual abort uses `AbortError`. */
35
+ function isAbortLike(error: unknown): boolean {
36
+ return (
37
+ isRecord(error) &&
38
+ (error.name === "TimeoutError" || error.name === "AbortError")
39
+ );
40
+ }
41
+
42
+ function normalizeBalance(raw: unknown): LLMBalance | null {
43
+ if (!isRecord(raw) || typeof raw.provider !== "string") return null;
44
+
45
+ return {
46
+ provider: raw.provider,
47
+ limit: numberOrNull(raw.limit),
48
+ limitRemaining: numberOrNull(raw.limit_remaining),
49
+ usage: numberOrNull(raw.usage) ?? 0,
50
+ usageDaily: numberOrNull(raw.usage_daily),
51
+ usageWeekly: numberOrNull(raw.usage_weekly),
52
+ usageMonthly: numberOrNull(raw.usage_monthly),
53
+ isFreeTier: raw.is_free_tier === true,
54
+ };
55
+ }
56
+
57
+ class LLMBalanceService {
58
+ /**
59
+ * Fetch the provider balance from the agent server.
60
+ *
61
+ * Returns `null` when the server answers 404 (older servers without the
62
+ * endpoint, or a provider that doesn't support balance reporting) or when
63
+ * the response doesn't match the expected shape — callers hide the balance
64
+ * UI in that case. Other HTTP failures throw, as does a request that outlives
65
+ * `LLM_BALANCE_TIMEOUT_MS`. A timeout stays an error rather than collapsing
66
+ * into `null`: `null` means "this server has no balance to report" and is
67
+ * cached under `staleTime: Infinity`, so treating a transient stall as an
68
+ * absent endpoint would hide the card for the rest of the conversation.
69
+ */
70
+ static async getBalance(): Promise<LLMBalance | null> {
71
+ const { host, apiKey } = getAgentServerClientOptions();
72
+ const client = new AgentServerClient({
73
+ host,
74
+ apiKey,
75
+ timeout: LLM_BALANCE_TIMEOUT_MS,
76
+ });
77
+ try {
78
+ const data = await client.get<unknown>(LLM_BALANCE_PATH, {
79
+ acceptableStatusCodes: new Set([200]),
80
+ responseType: "json",
81
+ timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000),
82
+ });
83
+ return normalizeBalance(data);
84
+ } catch (error) {
85
+ // Older servers without balance support answer 404 — hide the UI.
86
+ if (
87
+ error instanceof Error &&
88
+ "status" in error &&
89
+ (error as { status: number }).status === 404
90
+ ) {
91
+ return null;
92
+ }
93
+ if (isAbortLike(error)) {
94
+ throw new Error(
95
+ `Balance request timed out after ${LLM_BALANCE_TIMEOUT_MS}ms`,
96
+ );
97
+ }
98
+ const status =
99
+ error instanceof Error &&
100
+ "status" in error &&
101
+ (error as { status: number }).status;
102
+ throw new Error(
103
+ status
104
+ ? `Balance request failed with ${status}`
105
+ : `Balance request failed: ${error instanceof Error ? error.message : String(error)}`,
106
+ );
107
+ } finally {
108
+ client.close();
109
+ }
110
+ }
111
+ }
112
+
113
+ export default LLMBalanceService;
src/api/llm-subscription-service.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
2
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
3
+ import {
4
+ OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH,
5
+ OPENAI_SUBSCRIPTION_DEVICE_START_PATH,
6
+ OPENAI_SUBSCRIPTION_LOGOUT_PATH,
7
+ OPENAI_SUBSCRIPTION_MODELS_PATH,
8
+ OPENAI_SUBSCRIPTION_STATUS_PATH,
9
+ OPENAI_SUBSCRIPTION_VENDOR,
10
+ } from "#/constants/llm-subscription";
11
+
12
+ type RawSubscriptionStatus = Record<string, unknown>;
13
+ type RawDeviceStart = Record<string, unknown>;
14
+
15
+ export interface LLMSubscriptionStatus {
16
+ vendor: typeof OPENAI_SUBSCRIPTION_VENDOR;
17
+ connected: boolean;
18
+ accountEmail: string | null;
19
+ expiresAt: string | number | null;
20
+ }
21
+
22
+ export interface LLMSubscriptionDeviceChallenge {
23
+ deviceCode: string;
24
+ userCode: string;
25
+ verificationUri: string;
26
+ verificationUriComplete: string | null;
27
+ expiresAt: string | number | null;
28
+ intervalSeconds: number | null;
29
+ }
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ const readString = (
36
+ value: Record<string, unknown>,
37
+ keys: string[],
38
+ ): string | null => {
39
+ for (const key of keys) {
40
+ const candidate = value[key];
41
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
42
+ return candidate.trim();
43
+ }
44
+ }
45
+ return null;
46
+ };
47
+
48
+ const readNumber = (
49
+ value: Record<string, unknown>,
50
+ keys: string[],
51
+ ): number | null => {
52
+ for (const key of keys) {
53
+ const candidate = value[key];
54
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
55
+ return candidate;
56
+ }
57
+ }
58
+ return null;
59
+ };
60
+
61
+ const readBoolean = (
62
+ value: Record<string, unknown>,
63
+ keys: string[],
64
+ ): boolean => {
65
+ for (const key of keys) {
66
+ const candidate = value[key];
67
+ if (typeof candidate === "boolean") {
68
+ return candidate;
69
+ }
70
+ }
71
+ return false;
72
+ };
73
+
74
+ interface SubscriptionRequestOptions {
75
+ method?: "GET" | "POST";
76
+ jsonBody?: unknown;
77
+ }
78
+
79
+ async function requestSubscriptionEndpoint(
80
+ path: string,
81
+ options: SubscriptionRequestOptions = {},
82
+ ): Promise<unknown> {
83
+ const { host, apiKey } = getAgentServerClientOptions();
84
+ const client = new AgentServerClient({ host, apiKey });
85
+ try {
86
+ return await client.request<unknown>({
87
+ method: options.method ?? "GET",
88
+ path,
89
+ body: options.jsonBody,
90
+ responseType: "json",
91
+ });
92
+ } finally {
93
+ client.close();
94
+ }
95
+ }
96
+
97
+ function normalizeModels(raw: unknown): string[] {
98
+ if (Array.isArray(raw)) {
99
+ return raw.filter((item): item is string => typeof item === "string");
100
+ }
101
+ if (isRecord(raw) && Array.isArray(raw.models)) {
102
+ return raw.models.filter(
103
+ (item): item is string => typeof item === "string",
104
+ );
105
+ }
106
+ return [];
107
+ }
108
+
109
+ function normalizeStatus(raw: RawSubscriptionStatus): LLMSubscriptionStatus {
110
+ return {
111
+ vendor: OPENAI_SUBSCRIPTION_VENDOR,
112
+ connected: readBoolean(raw, ["connected", "authenticated", "is_connected"]),
113
+ accountEmail: readString(raw, ["account_email", "email", "account"]),
114
+ expiresAt:
115
+ readString(raw, ["expires_at", "expiresAt"]) ??
116
+ readNumber(raw, ["expires_at", "expiresAt"]),
117
+ };
118
+ }
119
+
120
+ function normalizeDeviceChallenge(
121
+ raw: RawDeviceStart,
122
+ ): LLMSubscriptionDeviceChallenge {
123
+ const deviceCode = readString(raw, ["device_code", "deviceCode"]);
124
+ const userCode = readString(raw, ["user_code", "userCode"]);
125
+ const verificationUri = readString(raw, [
126
+ "verification_uri",
127
+ "verificationUri",
128
+ "verification_url",
129
+ "verificationUrl",
130
+ ]);
131
+
132
+ if (!deviceCode || !userCode || !verificationUri) {
133
+ throw new Error("Subscription device login response is incomplete");
134
+ }
135
+
136
+ return {
137
+ deviceCode,
138
+ userCode,
139
+ verificationUri,
140
+ verificationUriComplete: readString(raw, [
141
+ "verification_uri_complete",
142
+ "verificationUriComplete",
143
+ "verification_url_complete",
144
+ "verificationUrlComplete",
145
+ ]),
146
+ expiresAt:
147
+ readString(raw, ["expires_at", "expiresAt"]) ??
148
+ readNumber(raw, ["expires_at", "expiresAt", "expires_in", "expiresIn"]),
149
+ intervalSeconds: readNumber(raw, [
150
+ "interval",
151
+ "interval_seconds",
152
+ "intervalSeconds",
153
+ ]),
154
+ };
155
+ }
156
+
157
+ class LLMSubscriptionService {
158
+ static async getOpenAIModels(): Promise<string[]> {
159
+ const response = await requestSubscriptionEndpoint(
160
+ OPENAI_SUBSCRIPTION_MODELS_PATH,
161
+ );
162
+ return normalizeModels(response);
163
+ }
164
+
165
+ static async getOpenAIStatus(): Promise<LLMSubscriptionStatus> {
166
+ const response = await requestSubscriptionEndpoint(
167
+ OPENAI_SUBSCRIPTION_STATUS_PATH,
168
+ );
169
+ return normalizeStatus(response as RawSubscriptionStatus);
170
+ }
171
+
172
+ static async startOpenAIDeviceLogin(): Promise<LLMSubscriptionDeviceChallenge> {
173
+ const response = await requestSubscriptionEndpoint(
174
+ OPENAI_SUBSCRIPTION_DEVICE_START_PATH,
175
+ { method: "POST" },
176
+ );
177
+ return normalizeDeviceChallenge(response as RawDeviceStart);
178
+ }
179
+
180
+ static async pollOpenAIDeviceLogin(
181
+ deviceCode: string,
182
+ ): Promise<LLMSubscriptionStatus> {
183
+ const response = await requestSubscriptionEndpoint(
184
+ OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH,
185
+ {
186
+ method: "POST",
187
+ jsonBody: { device_code: deviceCode },
188
+ },
189
+ );
190
+ return normalizeStatus(response as RawSubscriptionStatus);
191
+ }
192
+
193
+ static async logoutOpenAI(): Promise<LLMSubscriptionStatus> {
194
+ const response = await requestSubscriptionEndpoint(
195
+ OPENAI_SUBSCRIPTION_LOGOUT_PATH,
196
+ { method: "POST" },
197
+ );
198
+ return normalizeStatus(response as RawSubscriptionStatus);
199
+ }
200
+ }
201
+
202
+ export default LLMSubscriptionService;
src/api/main-app-auth.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from "axios";
2
+ import { AGENT_CANVAS_CLIENT_VERSION } from "./client-source";
3
+ import { getLockedCloudAuthMode } from "./agent-server-config";
4
+
5
+ export const MAIN_APP_AUTHENTICATE_PATH = "/api/authenticate";
6
+ export const MAIN_APP_ANALYTICS_EVENTS_PATH = "/api/analytics/events";
7
+ export const MAIN_APP_LOGIN_PATH = "/login";
8
+ export const MAIN_APP_LOGIN_REDIRECT_PARAM = "returnTo";
9
+
10
+ const LOCAL_BROWSER_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
11
+
12
+ function getCurrentPathForRedirect(): string {
13
+ if (typeof window === "undefined") return "/";
14
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
15
+ }
16
+
17
+ export function isLocalBrowserHost(): boolean {
18
+ if (typeof window === "undefined") return false;
19
+ return LOCAL_BROWSER_HOSTNAMES.has(window.location.hostname);
20
+ }
21
+
22
+ export function shouldUseMainAppCookieAuth(): boolean {
23
+ return getLockedCloudAuthMode() === "cookie" && !isLocalBrowserHost();
24
+ }
25
+
26
+ let canvasAuthenticatedReported = false;
27
+
28
+ export function _resetCanvasAuthReported(): void {
29
+ canvasAuthenticatedReported = false;
30
+ }
31
+
32
+ export async function reportCanvasAuthenticated(): Promise<void> {
33
+ if (canvasAuthenticatedReported) return;
34
+ canvasAuthenticatedReported = true;
35
+ try {
36
+ await axios.post(
37
+ MAIN_APP_ANALYTICS_EVENTS_PATH,
38
+ {
39
+ event_type: "canvas_authenticated",
40
+ client_version: AGENT_CANVAS_CLIENT_VERSION,
41
+ },
42
+ { withCredentials: true },
43
+ );
44
+ } catch {
45
+ // Analytics must never affect authentication or application startup.
46
+ }
47
+ }
48
+
49
+ export async function authenticateWithMainAppCookie(): Promise<boolean> {
50
+ try {
51
+ await axios.post(MAIN_APP_AUTHENTICATE_PATH, null, {
52
+ withCredentials: true,
53
+ });
54
+ void reportCanvasAuthenticated();
55
+ return true;
56
+ } catch (error) {
57
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
58
+ return false;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ export function buildMainAppLoginRedirect(
65
+ redirectPath = getCurrentPathForRedirect(),
66
+ ): string {
67
+ const params = new URLSearchParams({
68
+ [MAIN_APP_LOGIN_REDIRECT_PARAM]: redirectPath,
69
+ });
70
+ return `${MAIN_APP_LOGIN_PATH}?${params.toString()}`;
71
+ }
72
+
73
+ export function redirectToMainAppLogin(): void {
74
+ if (typeof window === "undefined") return;
75
+ window.location.assign(buildMainAppLoginRedirect());
76
+ }
src/api/no-direct-agent-server-calls.test.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+
5
+ const SRC_ROOT = join(process.cwd(), "src");
6
+ const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]);
7
+ const ALLOWED_AD_HOC_HTTP_FILES = new Set([
8
+ "api/automation-service/automation-service.api.ts",
9
+ "api/cloud/proxy.ts",
10
+ "api/main-app-auth.ts",
11
+ ]);
12
+
13
+ function collectSourceFiles(dir: string): string[] {
14
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
15
+ const fullPath = join(dir, entry.name);
16
+ // Normalize to forward slashes so the path matches ALLOWED_AD_HOC_HTTP_FILES
17
+ // entries on Windows where path.relative() returns backslash-separated paths.
18
+ const relPath = relative(SRC_ROOT, fullPath).replace(/\\/g, "/");
19
+
20
+ if (entry.isDirectory()) {
21
+ if (EXCLUDED_SEGMENTS.has(entry.name)) return [];
22
+ return collectSourceFiles(fullPath);
23
+ }
24
+
25
+ if (EXCLUDED_SEGMENTS.has(entry.name)) return [];
26
+ if (!/\.(ts|tsx)$/.test(entry.name)) return [];
27
+ if (/\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return [];
28
+ return [relPath];
29
+ });
30
+ }
31
+
32
+ describe("agent-server API access", () => {
33
+ it("uses typed @openhands/typescript-client access instead of ad-hoc HTTP", () => {
34
+ const violations = collectSourceFiles(SRC_ROOT).flatMap((relPath) => {
35
+ const source = readFileSync(join(SRC_ROOT, relPath), "utf8");
36
+ const fileViolations: string[] = [];
37
+
38
+ if (/openHands\s*\./.test(source)) {
39
+ fileViolations.push("uses the shared axios instance directly");
40
+ }
41
+
42
+ if (/\bcreateHttpClient\s*\(/.test(source)) {
43
+ fileViolations.push("uses createHttpClient directly");
44
+ }
45
+
46
+ if (
47
+ /from\s+["']@openhands\/typescript-client\/client\/http-client["']/.test(
48
+ source,
49
+ )
50
+ ) {
51
+ fileViolations.push("imports the low-level SDK HttpClient directly");
52
+ }
53
+
54
+ if (/\bnew\s+HttpClient\s*\(/.test(source)) {
55
+ fileViolations.push("constructs HttpClient directly");
56
+ }
57
+
58
+ if (
59
+ (/\baxios\s*\(/.test(source) ||
60
+ /\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/.test(
61
+ source,
62
+ )) &&
63
+ !ALLOWED_AD_HOC_HTTP_FILES.has(relPath)
64
+ ) {
65
+ fileViolations.push("uses axios directly for HTTP calls");
66
+ }
67
+
68
+ if (
69
+ /\bfetch\s*\([\s\S]{0,200}['"`]\/api\//.test(source) &&
70
+ !ALLOWED_AD_HOC_HTTP_FILES.has(relPath)
71
+ ) {
72
+ fileViolations.push("calls an /api path with fetch directly");
73
+ }
74
+
75
+ return fileViolations.map((violation) => `${relPath}: ${violation}`);
76
+ });
77
+
78
+ expect(violations).toEqual([]);
79
+ });
80
+ });
src/api/open-hands.types.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ConversationStatus } from "#/types/conversation-status";
2
+ import { RuntimeStatus } from "#/types/runtime-status";
3
+ import { Provider } from "#/types/settings";
4
+
5
+ export interface ErrorResponse {
6
+ error: string;
7
+ }
8
+
9
+ export interface SaveFileSuccessResponse {
10
+ message: string;
11
+ }
12
+
13
+ export interface FileUploadSuccessResponse {
14
+ uploaded_files: string[];
15
+ skipped_files: { name: string; reason: string }[];
16
+ }
17
+
18
+ export interface FeedbackBodyResponse {
19
+ message: string;
20
+ feedback_id: string;
21
+ password: string;
22
+ }
23
+
24
+ export interface FeedbackResponse {
25
+ statusCode: number;
26
+ body: FeedbackBodyResponse;
27
+ }
28
+
29
+ export interface AuthenticationResponse {
30
+ message: string;
31
+ login?: string; // Only present when allow list is enabled
32
+ }
33
+
34
+ export interface Feedback {
35
+ version: string;
36
+ email: string;
37
+ token: string;
38
+ polarity: "positive" | "negative";
39
+ permissions: "public" | "private";
40
+ trajectory: unknown[];
41
+ }
42
+
43
+ export interface GetVSCodeUrlResponse {
44
+ vscode_url: string | null;
45
+ error?: string;
46
+ }
47
+
48
+ export interface GetTrajectoryResponse {
49
+ trajectory: unknown[] | null;
50
+ error?: string;
51
+ }
52
+
53
+ export interface RepositorySelection {
54
+ selected_repository: string | null;
55
+ selected_branch: string | null;
56
+ git_provider: Provider | null;
57
+ }
58
+
59
+ export type ConversationTrigger =
60
+ | "resolver"
61
+ | "gui"
62
+ | "suggested_task"
63
+ | "microagent_management"
64
+ | "automation";
65
+
66
+ export interface Conversation {
67
+ conversation_id: string;
68
+ title: string;
69
+ selected_repository: string | null;
70
+ selected_branch: string | null;
71
+ git_provider: Provider | null;
72
+ last_updated_at: string;
73
+ created_at: string;
74
+ status: ConversationStatus;
75
+ runtime_status: RuntimeStatus | null;
76
+ trigger?: ConversationTrigger;
77
+ url: string | null;
78
+ session_api_key: string | null;
79
+ pr_number?: number[] | null;
80
+ conversation_version?: "V0" | "V1";
81
+ sub_conversation_ids?: string[];
82
+ public?: boolean;
83
+ llm_model?: string | null;
84
+ }
85
+
86
+ export interface ResultSet<T> {
87
+ results: T[];
88
+ next_page_id: string | null;
89
+ }
90
+
91
+ /**
92
+ * @deprecated Use AgentServerGitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
93
+ */
94
+ export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
95
+
96
+ export type AgentServerGitChangeStatus =
97
+ | "MOVED"
98
+ | "ADDED"
99
+ | "DELETED"
100
+ | "UPDATED";
101
+
102
+ export interface GitChange {
103
+ status: GitChangeStatus;
104
+ path: string;
105
+ }
106
+
107
+ export interface GitChangeDiff {
108
+ modified: string;
109
+ original: string;
110
+ }
111
+
112
+ export interface GitCommit {
113
+ sha: string;
114
+ shortSha: string;
115
+ subject: string;
116
+ author: string;
117
+ // ISO 8601 author date with UTC offset (git log %aI).
118
+ timestamp: string;
119
+ }
120
+
121
+ export interface GitCommitsPage {
122
+ commits: GitCommit[];
123
+ hasMore: boolean;
124
+ }
125
+
126
+ export interface InputMetadata {
127
+ name: string;
128
+ description: string;
129
+ }
130
+
131
+ export interface IOption<T> {
132
+ label: string;
133
+ value: T;
134
+ }
135
+
136
+ export type GetFilesResponse = string[];
137
+
138
+ export interface GetFileResponse {
139
+ code: string;
140
+ }
src/api/plugins-management-service.test.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PluginsClient } from "@openhands/typescript-client/clients";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ __resetActiveStoreForTests,
5
+ setActiveSelection,
6
+ setRegisteredBackends,
7
+ } from "#/api/backend-registry/active-store";
8
+ import type { Backend } from "#/api/backend-registry/types";
9
+ import PluginsManagementService from "#/api/plugins-management-service";
10
+
11
+ vi.mock("@openhands/typescript-client/clients", () => ({
12
+ PluginsClient: vi.fn(),
13
+ }));
14
+
15
+ const listInstalledPlugins = vi.fn();
16
+ const installPlugin = vi.fn();
17
+ const setPluginEnabled = vi.fn();
18
+ const uninstallPlugin = vi.fn();
19
+ const refreshPlugin = vi.fn();
20
+ const close = vi.fn();
21
+
22
+ function useBackend(kind: "local" | "cloud"): void {
23
+ const backend: Backend = {
24
+ id: kind,
25
+ name: kind,
26
+ host: "http://127.0.0.1:8001",
27
+ apiKey: "session-key",
28
+ kind,
29
+ };
30
+ setRegisteredBackends([backend]);
31
+ setActiveSelection({ backendId: kind, orgId: null });
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ __resetActiveStoreForTests();
37
+ vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
38
+ return {
39
+ listInstalledPlugins,
40
+ installPlugin,
41
+ setPluginEnabled,
42
+ uninstallPlugin,
43
+ refreshPlugin,
44
+ close,
45
+ } as unknown as PluginsClient;
46
+ } as unknown as typeof PluginsClient);
47
+ });
48
+
49
+ afterEach(() => {
50
+ __resetActiveStoreForTests();
51
+ });
52
+
53
+ const installedPlugin = {
54
+ name: "demo-plugin",
55
+ version: "1.0.0",
56
+ description: "A demo plugin",
57
+ enabled: true,
58
+ source: "github:OpenHands/extensions",
59
+ resolved_ref: null,
60
+ repo_path: "plugins/demo-plugin",
61
+ installed_at: "2026-06-01T00:00:00Z",
62
+ install_path: "/home/.openhands/plugins/installed/demo-plugin",
63
+ };
64
+
65
+ describe("PluginsManagementService", () => {
66
+ it("lists installed plugins from the local agent-server", async () => {
67
+ useBackend("local");
68
+ listInstalledPlugins.mockResolvedValue({ plugins: [installedPlugin] });
69
+
70
+ const result = await PluginsManagementService.listInstalledPlugins();
71
+
72
+ expect(result).toEqual([installedPlugin]);
73
+ expect(listInstalledPlugins).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("returns an empty installed list on a cloud backend without calling the client", async () => {
77
+ useBackend("cloud");
78
+
79
+ const result = await PluginsManagementService.listInstalledPlugins();
80
+
81
+ expect(result).toEqual([]);
82
+ expect(PluginsClient).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("returns an empty installed list when the local request fails", async () => {
86
+ useBackend("local");
87
+ listInstalledPlugins.mockRejectedValue(new Error("unreachable"));
88
+
89
+ const result = await PluginsManagementService.listInstalledPlugins();
90
+
91
+ expect(result).toEqual([]);
92
+ });
93
+
94
+ it("forwards source, ref, and repo_path when installing a plugin", async () => {
95
+ useBackend("local");
96
+ installPlugin.mockResolvedValue(installedPlugin);
97
+
98
+ await PluginsManagementService.installPlugin({
99
+ source: "github:OpenHands/extensions",
100
+ ref: "main",
101
+ repo_path: "plugins/demo-plugin",
102
+ });
103
+
104
+ expect(installPlugin).toHaveBeenCalledWith({
105
+ source: "github:OpenHands/extensions",
106
+ ref: "main",
107
+ repo_path: "plugins/demo-plugin",
108
+ });
109
+ });
110
+
111
+ it("forwards the name and enabled flag when toggling a plugin", async () => {
112
+ useBackend("local");
113
+ setPluginEnabled.mockResolvedValue({ name: "demo-plugin", enabled: false });
114
+
115
+ await PluginsManagementService.setPluginEnabled("demo-plugin", false);
116
+
117
+ expect(setPluginEnabled).toHaveBeenCalledWith("demo-plugin", false);
118
+ });
119
+
120
+ it("forwards the plugin name when uninstalling", async () => {
121
+ useBackend("local");
122
+ uninstallPlugin.mockResolvedValue({ message: "ok" });
123
+
124
+ await PluginsManagementService.uninstallPlugin("demo-plugin");
125
+
126
+ expect(uninstallPlugin).toHaveBeenCalledWith("demo-plugin");
127
+ });
128
+
129
+ it("forwards the plugin name when refreshing", async () => {
130
+ useBackend("local");
131
+ refreshPlugin.mockResolvedValue({ message: "ok", plugin: installedPlugin });
132
+
133
+ await PluginsManagementService.refreshPlugin("demo-plugin");
134
+
135
+ expect(refreshPlugin).toHaveBeenCalledWith("demo-plugin");
136
+ });
137
+ });
src/api/plugins-management-service.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PluginsClient } from "@openhands/typescript-client/clients";
2
+ import { getActiveBackend } from "./backend-registry/active-store";
3
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
4
+ import type { PluginBundledSkill } from "./plugins-service";
5
+
6
+ /**
7
+ * An installed plugin, as returned by the agent-server management router
8
+ * (`GET /api/plugins/installed`). Matches the typescript-client
9
+ * `InstalledPluginInfo`. The contents fields (`skills`, `files`, relative to
10
+ * `install_path`) are absent on older agent-servers and null when the
11
+ * installed plugin directory failed to load.
12
+ */
13
+ export interface InstalledPluginInfo {
14
+ name: string;
15
+ version: string;
16
+ description: string | null;
17
+ enabled: boolean;
18
+ source: string;
19
+ resolved_ref?: string | null;
20
+ repo_path?: string | null;
21
+ installed_at: string;
22
+ install_path: string;
23
+ skills?: PluginBundledSkill[] | null;
24
+ files?: string[] | null;
25
+ }
26
+
27
+ /** Coordinates for installing a plugin from a git source or local path. */
28
+ export interface InstallPluginRequest {
29
+ source: string;
30
+ ref?: string | null;
31
+ repo_path?: string | null;
32
+ force?: boolean;
33
+ }
34
+
35
+ /**
36
+ * The slice of the typescript-client `PluginsClient` this service drives. The
37
+ * installed `@openhands/typescript-client` package does not yet export these
38
+ * management methods (they ship in typescript-client PRs #222/#223); narrowing
39
+ * to this local interface keeps `vitest` (types stripped) and `eslint` green
40
+ * until the client is republished, at which point the cast below can be dropped.
41
+ */
42
+ interface PluginsManagementClient {
43
+ listInstalledPlugins(): Promise<{ plugins: InstalledPluginInfo[] }>;
44
+ installPlugin(request: InstallPluginRequest): Promise<InstalledPluginInfo>;
45
+ setPluginEnabled(
46
+ name: string,
47
+ enabled: boolean,
48
+ ): Promise<{ name: string; enabled: boolean }>;
49
+ uninstallPlugin(name: string): Promise<{ message: string }>;
50
+ refreshPlugin(
51
+ name: string,
52
+ ): Promise<{ message: string; plugin: InstalledPluginInfo }>;
53
+ }
54
+
55
+ function isCloudBackend(): boolean {
56
+ return getActiveBackend().backend.kind === "cloud";
57
+ }
58
+
59
+ function getManagementClient(): PluginsManagementClient {
60
+ return new PluginsClient(
61
+ getAgentServerClientOptions(),
62
+ ) as unknown as PluginsManagementClient;
63
+ }
64
+
65
+ /**
66
+ * Front-end management layer for installed plugins: list / install / enable /
67
+ * disable / uninstall / refresh. Kept separate from the read-only catalog
68
+ * service (`plugins-service.ts`), exactly as skills separate the marketplace
69
+ * catalog from install actions.
70
+ *
71
+ * Local backend only for now (per Appendix C Q5): installed plugins live on the
72
+ * local agent-server's `~/.openhands/plugins/installed/`. A cloud backend has no
73
+ * per-user installed store yet, so reads return an empty list and mutating
74
+ * actions throw (the UI also disables them on cloud).
75
+ */
76
+ class PluginsManagementService {
77
+ static async listInstalledPlugins(): Promise<InstalledPluginInfo[]> {
78
+ if (isCloudBackend()) {
79
+ return [];
80
+ }
81
+
82
+ try {
83
+ const response = await getManagementClient().listInstalledPlugins();
84
+ return response.plugins ?? [];
85
+ } catch {
86
+ // Agent-server may predate the plugins router or be unreachable; surface
87
+ // an empty list rather than throwing (mirrors the catalog service).
88
+ return [];
89
+ }
90
+ }
91
+
92
+ static async installPlugin(
93
+ request: InstallPluginRequest,
94
+ ): Promise<InstalledPluginInfo> {
95
+ if (isCloudBackend()) {
96
+ throw new Error(
97
+ "Installing plugins is only available on a local backend.",
98
+ );
99
+ }
100
+ return getManagementClient().installPlugin(request);
101
+ }
102
+
103
+ static async setPluginEnabled(
104
+ name: string,
105
+ enabled: boolean,
106
+ ): Promise<{ name: string; enabled: boolean }> {
107
+ if (isCloudBackend()) {
108
+ throw new Error(
109
+ "Enabling and disabling plugins is only available on a local backend.",
110
+ );
111
+ }
112
+ return getManagementClient().setPluginEnabled(name, enabled);
113
+ }
114
+
115
+ static async uninstallPlugin(name: string): Promise<{ message: string }> {
116
+ if (isCloudBackend()) {
117
+ throw new Error(
118
+ "Uninstalling plugins is only available on a local backend.",
119
+ );
120
+ }
121
+ return getManagementClient().uninstallPlugin(name);
122
+ }
123
+
124
+ static async refreshPlugin(
125
+ name: string,
126
+ ): Promise<{ message: string; plugin: InstalledPluginInfo }> {
127
+ if (isCloudBackend()) {
128
+ throw new Error(
129
+ "Refreshing plugins is only available on a local backend.",
130
+ );
131
+ }
132
+ return getManagementClient().refreshPlugin(name);
133
+ }
134
+ }
135
+
136
+ export default PluginsManagementService;
src/api/plugins-service.test.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ FileClient,
4
+ PluginsClient,
5
+ } from "@openhands/typescript-client/clients";
6
+ import {
7
+ setActiveSelection,
8
+ setRegisteredBackends,
9
+ } from "./backend-registry/active-store";
10
+ import PluginsService from "./plugins-service";
11
+
12
+ vi.mock("@openhands/typescript-client/clients", () => ({
13
+ PluginsClient: vi.fn(),
14
+ FileClient: vi.fn(),
15
+ }));
16
+
17
+ const getPluginsMarketplace = vi.fn();
18
+ const getPlugins = vi.fn();
19
+ const downloadFile = vi.fn();
20
+ const close = vi.fn();
21
+
22
+ function useBackend(kind: "local" | "cloud"): void {
23
+ setRegisteredBackends([
24
+ {
25
+ id: kind,
26
+ name: kind,
27
+ host: "http://127.0.0.1:8001",
28
+ apiKey: "session-key",
29
+ kind,
30
+ },
31
+ ]);
32
+ setActiveSelection({ backendId: kind, orgId: null });
33
+ }
34
+
35
+ describe("PluginsService.getPluginsMarketplace", () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
39
+ return { getPluginsMarketplace, close } as unknown as PluginsClient;
40
+ } as unknown as typeof PluginsClient);
41
+ });
42
+
43
+ it("returns the catalog from the local agent-server", async () => {
44
+ useBackend("local");
45
+ const plugin = {
46
+ name: "city-weather",
47
+ description: "Weather plugin",
48
+ source: "github:OpenHands/extensions",
49
+ ref: null,
50
+ repo_path: "plugins/city-weather",
51
+ installed: false,
52
+ };
53
+ getPluginsMarketplace.mockResolvedValue({ plugins: [plugin] });
54
+
55
+ const result = await PluginsService.getPluginsMarketplace();
56
+
57
+ expect(result).toEqual([plugin]);
58
+ expect(getPluginsMarketplace).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("returns an empty catalog on a cloud backend without calling the client", async () => {
62
+ useBackend("cloud");
63
+
64
+ const result = await PluginsService.getPluginsMarketplace();
65
+
66
+ expect(result).toEqual([]);
67
+ expect(PluginsClient).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("returns an empty catalog when the local request fails", async () => {
71
+ useBackend("local");
72
+ getPluginsMarketplace.mockRejectedValue(new Error("unreachable"));
73
+
74
+ const result = await PluginsService.getPluginsMarketplace();
75
+
76
+ expect(result).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe("PluginsService.getLocalPlugins", () => {
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
84
+ return { getPlugins, close } as unknown as PluginsClient;
85
+ } as unknown as typeof PluginsClient);
86
+ });
87
+
88
+ it("requests user-level local plugins from the local agent-server", async () => {
89
+ useBackend("local");
90
+ const plugin = {
91
+ name: "hello-local",
92
+ version: "1.0.0",
93
+ description: "A local plugin",
94
+ };
95
+ getPlugins.mockResolvedValue({ plugins: [plugin] });
96
+
97
+ const result = await PluginsService.getLocalPlugins();
98
+
99
+ expect(result).toEqual([plugin]);
100
+ expect(getPlugins).toHaveBeenCalledWith({
101
+ load_user: true,
102
+ load_project: false,
103
+ });
104
+ });
105
+
106
+ it("returns an empty list on a cloud backend without calling the client", async () => {
107
+ useBackend("cloud");
108
+
109
+ const result = await PluginsService.getLocalPlugins();
110
+
111
+ expect(result).toEqual([]);
112
+ expect(PluginsClient).not.toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe("PluginsService.getPluginFileContent", () => {
117
+ beforeEach(() => {
118
+ vi.clearAllMocks();
119
+ vi.mocked(FileClient).mockImplementation(function MockFileClient() {
120
+ return { downloadFile, close } as unknown as FileClient;
121
+ } as unknown as typeof FileClient);
122
+ });
123
+
124
+ it("downloads the file under the plugin directory and decodes it as text", async () => {
125
+ useBackend("local");
126
+ downloadFile.mockResolvedValue(new TextEncoder().encode("# Hello").buffer);
127
+
128
+ const result = await PluginsService.getPluginFileContent(
129
+ "/plugins/demo",
130
+ "docs/README.md",
131
+ );
132
+
133
+ expect(result).toEqual({ kind: "text", text: "# Hello" });
134
+ expect(downloadFile).toHaveBeenCalledWith("/plugins/demo/docs/README.md");
135
+ });
136
+
137
+ it("flags content containing NUL bytes as binary", async () => {
138
+ useBackend("local");
139
+ downloadFile.mockResolvedValue(
140
+ new Uint8Array([0x89, 0x50, 0x00, 0x47]).buffer,
141
+ );
142
+
143
+ const result = await PluginsService.getPluginFileContent(
144
+ "/plugins/demo",
145
+ "logo.png",
146
+ );
147
+
148
+ expect(result).toEqual({ kind: "binary", text: null });
149
+ });
150
+
151
+ it("rejects on a cloud backend without calling the client", async () => {
152
+ useBackend("cloud");
153
+
154
+ await expect(
155
+ PluginsService.getPluginFileContent("/plugins/demo", "README.md"),
156
+ ).rejects.toThrow();
157
+ expect(FileClient).not.toHaveBeenCalled();
158
+ });
159
+ });
src/api/plugins-service.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ FileClient,
3
+ PluginsClient,
4
+ } from "@openhands/typescript-client/clients";
5
+ import { getActiveBackend } from "./backend-registry/active-store";
6
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
7
+
8
+ /** Summary of a skill bundled in a plugin (agent-server `PluginSkillSummary`). */
9
+ export interface PluginBundledSkill {
10
+ name: string;
11
+ description?: string | null;
12
+ }
13
+
14
+ /**
15
+ * A plugin in the dynamic marketplace catalog, with attachable coordinates and
16
+ * install state. Matches the agent-server `MarketplacePluginInfo` / the
17
+ * typescript-client `MarketplacePlugin`. The contents fields (`path`, `skills`,
18
+ * `files`) are populated when the entry resolves to a directory in the
19
+ * server's local marketplace clone, and are absent on older agent-servers.
20
+ */
21
+ export interface MarketplacePlugin {
22
+ name: string;
23
+ description: string | null;
24
+ source: string;
25
+ ref?: string | null;
26
+ repo_path?: string | null;
27
+ installed: boolean;
28
+ path?: string | null;
29
+ skills?: PluginBundledSkill[] | null;
30
+ files?: string[] | null;
31
+ }
32
+
33
+ /**
34
+ * A locally-discovered ("ambient") plugin reported by the agent-server — one
35
+ * found in the user's local plugin directories (e.g. `~/.agents/plugins`).
36
+ * These auto-load into conversations and are not managed via install/uninstall,
37
+ * so the Plugins page renders them as a read-only "Local" group. Matches the
38
+ * typescript-client `PluginInfo`; the contents fields are absent on older
39
+ * agent-servers.
40
+ */
41
+ export interface LocalPlugin {
42
+ name: string;
43
+ version: string;
44
+ description: string;
45
+ path?: string;
46
+ skills?: PluginBundledSkill[];
47
+ files?: string[];
48
+ }
49
+
50
+ /** Content of a single plugin file fetched for the detail-modal viewer. */
51
+ export interface PluginFileContent {
52
+ kind: "text" | "binary";
53
+ text: string | null;
54
+ }
55
+
56
+ function isLikelyBinary(buffer: ArrayBuffer): boolean {
57
+ // Same heuristic git uses: presence of a NUL byte in the first ~8KB. Small
58
+ // private copy of `isLikelyBinary` in `use-workspace-file-content.ts` — that
59
+ // module is conversation-workspace-specific and heavy to import from here.
60
+ const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000));
61
+ for (let i = 0; i < view.length; i += 1) {
62
+ if (view[i] === 0) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ class PluginsService {
68
+ /**
69
+ * Fetch the dynamic plugins marketplace catalog.
70
+ *
71
+ * Local backend only for now: the catalog is fetched at run time from the
72
+ * agent-server via the typed client (no bundled catalog, so the list stays
73
+ * dynamic). On a cloud backend an empty catalog is returned — there is no
74
+ * cloud plugins-marketplace endpoint yet (tracked as a follow-up ticket).
75
+ */
76
+ static async getPluginsMarketplace(): Promise<MarketplacePlugin[]> {
77
+ if (getActiveBackend().backend.kind === "cloud") {
78
+ return [];
79
+ }
80
+
81
+ try {
82
+ const response = await new PluginsClient(
83
+ getAgentServerClientOptions(),
84
+ ).getPluginsMarketplace();
85
+ return (response.plugins ?? []) as MarketplacePlugin[];
86
+ } catch {
87
+ // Agent-server may not support the plugins endpoint or be unreachable;
88
+ // surface an empty catalog rather than throwing.
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Fetch the locally-discovered ("ambient") plugins from the agent-server.
95
+ *
96
+ * Only user-level plugins are requested (`~/.agents/plugins`,
97
+ * `~/.openhands/plugins`, plus enabled installed plugins): the Plugins page is
98
+ * global, so there is no project workspace to scope project plugins to.
99
+ *
100
+ * Local backend only — a cloud backend has no local plugin directories, so an
101
+ * empty list is returned. Errors surface as an empty list (mirrors the
102
+ * catalog) rather than throwing.
103
+ */
104
+ static async getLocalPlugins(): Promise<LocalPlugin[]> {
105
+ if (getActiveBackend().backend.kind === "cloud") {
106
+ return [];
107
+ }
108
+
109
+ try {
110
+ const response = await new PluginsClient(
111
+ getAgentServerClientOptions(),
112
+ ).getPlugins({ load_user: true, load_project: false });
113
+ return (response.plugins ?? []) as LocalPlugin[];
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Fetch one plugin file's content for the detail-modal viewer. `basePath` is
121
+ * the plugin directory reported by the agent-server (`path`/`install_path`)
122
+ * and `relativePath` a POSIX path from the plugin's `files` listing.
123
+ *
124
+ * Local backend only — plugin files live on the local agent-server's disk.
125
+ * Errors propagate so the caller can render a load-error state.
126
+ */
127
+ static async getPluginFileContent(
128
+ basePath: string,
129
+ relativePath: string,
130
+ ): Promise<PluginFileContent> {
131
+ if (getActiveBackend().backend.kind === "cloud") {
132
+ throw new Error(
133
+ "Reading plugin files is only available on a local backend.",
134
+ );
135
+ }
136
+
137
+ const buffer = await new FileClient(
138
+ getAgentServerClientOptions(),
139
+ ).downloadFile(`${basePath}/${relativePath}`);
140
+ if (isLikelyBinary(buffer)) {
141
+ return { kind: "binary", text: null };
142
+ }
143
+ return {
144
+ kind: "text",
145
+ text: new TextDecoder("utf-8", { fatal: false }).decode(buffer),
146
+ };
147
+ }
148
+ }
149
+
150
+ export default PluginsService;
src/api/secrets-service.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SettingsClient } from "@openhands/typescript-client/clients";
2
+ import { isSdkHttpStatusError } from "./agent-server-compatibility";
3
+ import { getActiveBackend } from "./backend-registry/active-store";
4
+ import {
5
+ deleteCloudSecret,
6
+ fetchCloudSecrets,
7
+ saveCloudSecret,
8
+ } from "./cloud/secrets-service.api";
9
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
10
+ import { CustomSecretWithoutValue } from "./secrets-service.types";
11
+ import { withRetry } from "./with-retry";
12
+
13
+ async function fetchSecrets(): Promise<CustomSecretWithoutValue[]> {
14
+ if (getActiveBackend().backend.kind === "cloud") {
15
+ return withRetry(() => fetchCloudSecrets());
16
+ }
17
+ const response = await withRetry(() =>
18
+ new SettingsClient(getAgentServerClientOptions()).listSecrets(),
19
+ );
20
+ return response.secrets.map((s) => ({
21
+ name: s.name,
22
+ description: s.description,
23
+ }));
24
+ }
25
+
26
+ export class SecretsService {
27
+ /**
28
+ * List all custom secrets (names and descriptions only, no values).
29
+ * Uses the agent-server API endpoint: GET /api/settings/secrets
30
+ *
31
+ * Note: The agent-server API doesn't support pagination or search filtering.
32
+ * All secrets are returned in a single response.
33
+ */
34
+ static async getSecrets(): Promise<CustomSecretWithoutValue[]> {
35
+ try {
36
+ return await fetchSecrets();
37
+ } catch (error) {
38
+ console.error("Failed to fetch secrets after retries:", error);
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /**
44
+ * List all custom secrets, surfacing failures to callers that must
45
+ * distinguish an unavailable list from an empty one.
46
+ */
47
+ static async getSecretsOrThrow(): Promise<CustomSecretWithoutValue[]> {
48
+ return fetchSecrets();
49
+ }
50
+
51
+ /**
52
+ * Create or update a custom secret (upsert by name).
53
+ * Uses the agent-server API endpoint: PUT /api/settings/secrets
54
+ *
55
+ * @param name - Secret name (must start with letter, contain only letters/numbers/underscores, 1-64 chars)
56
+ * @param value - Secret value
57
+ * @param description - Optional description
58
+ * @throws Error if the API call fails after retries
59
+ */
60
+ static async createSecret(
61
+ name: string,
62
+ value: string,
63
+ description?: string,
64
+ ): Promise<void> {
65
+ if (getActiveBackend().backend.kind === "cloud") {
66
+ await saveCloudSecret({ name, value, description });
67
+ return;
68
+ }
69
+ await withRetry(() =>
70
+ new SettingsClient(getAgentServerClientOptions()).upsertSecret({
71
+ name,
72
+ value,
73
+ description,
74
+ }),
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Update a secret's name and/or description, and optionally overwrite its
80
+ * value. When no value is given the existing one is preserved: the
81
+ * agent-server only exposes an upsert endpoint, so we fetch the existing
82
+ * value and re-upsert it under the updated name/description.
83
+ *
84
+ * @param secretToEdit - Existing secret name
85
+ * @param name - New (or same) secret name
86
+ * @param description - Optional new description
87
+ * @param value - Optional new value; when omitted the stored value is kept
88
+ * @throws Error if the API call fails after retries
89
+ */
90
+ static async updateSecret(
91
+ secretToEdit: string,
92
+ name: string,
93
+ description?: string,
94
+ value?: string,
95
+ ): Promise<void> {
96
+ if (getActiveBackend().backend.kind === "cloud") {
97
+ await saveCloudSecret({
98
+ name,
99
+ value,
100
+ description,
101
+ previousName: secretToEdit,
102
+ });
103
+ return;
104
+ }
105
+
106
+ const client = new SettingsClient(getAgentServerClientOptions());
107
+ const nextValue =
108
+ value ?? (await withRetry(() => client.getSecret(secretToEdit)));
109
+
110
+ await withRetry(() =>
111
+ client.upsertSecret({
112
+ name,
113
+ value: nextValue,
114
+ description,
115
+ }),
116
+ );
117
+
118
+ if (name !== secretToEdit) {
119
+ await this.deleteSecret(secretToEdit);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Delete a custom secret by name.
125
+ * Uses the agent-server API endpoint: DELETE /api/settings/secrets/{name}
126
+ *
127
+ * @param name - Secret name to delete
128
+ * @throws Error if the API call fails (except 404, which is treated as success)
129
+ */
130
+ static async deleteSecret(name: string): Promise<void> {
131
+ try {
132
+ if (getActiveBackend().backend.kind === "cloud") {
133
+ await withRetry(() => deleteCloudSecret(name));
134
+ return;
135
+ }
136
+ await withRetry(() =>
137
+ new SettingsClient(getAgentServerClientOptions()).deleteSecret(name),
138
+ );
139
+ } catch (error) {
140
+ // 404 means secret doesn't exist - treat as successful deletion.
141
+ // Both the SDK's HttpError (status on the error itself) and
142
+ // axios-style errors (status under `response`) count.
143
+ if (
144
+ isSdkHttpStatusError(error, 404) ||
145
+ (error &&
146
+ typeof error === "object" &&
147
+ "response" in error &&
148
+ (error as { response?: { status?: number } }).response?.status ===
149
+ 404)
150
+ ) {
151
+ return;
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+ }
src/api/secrets-service.types.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Custom secret with name, value, and optional description.
3
+ * Used for creating/updating secrets via PUT /api/settings/secrets.
4
+ */
5
+ export type CustomSecret = {
6
+ name: string;
7
+ value: string;
8
+ description?: string;
9
+ };
10
+
11
+ /**
12
+ * Custom secret metadata without the secret value.
13
+ * Used for listing secrets via GET /api/settings/secrets.
14
+ */
15
+ export type CustomSecretWithoutValue = Omit<CustomSecret, "value">;
src/api/skills-service.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SkillsClient } from "@openhands/typescript-client/clients";
2
+ import {
3
+ SKILLS_CATALOG,
4
+ type SkillCatalogEntry,
5
+ } from "@openhands/extensions/skills";
6
+ import { SkillInfo } from "#/types/settings";
7
+ import { getAgentServerWorkingDir } from "./agent-server-config";
8
+ import { getActiveBackend } from "./backend-registry/active-store";
9
+ import {
10
+ fetchCloudConversationSkills,
11
+ fetchCloudSkills,
12
+ } from "./cloud/skills-service.api";
13
+ import { getAgentServerClientOptions } from "./agent-server-client-options";
14
+
15
+ function catalogEntryToSkillInfo(entry: SkillCatalogEntry): SkillInfo {
16
+ return {
17
+ name: entry.name,
18
+ type: "knowledge",
19
+ source: "public",
20
+ description: entry.description,
21
+ triggers: entry.triggers,
22
+ category: entry.category,
23
+ content: entry.content,
24
+ license: entry.license ?? null,
25
+ compatibility: entry.compatibility ?? null,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Public skills loaded from the `@openhands/extensions` npm package.
31
+ *
32
+ * This is an **immutable build-time snapshot**: the catalog is baked into the
33
+ * bundle at `npm run build` / `vite build` time and does not change at
34
+ * runtime. Updating the catalog requires bumping the `@openhands/extensions`
35
+ * dependency and rebuilding.
36
+ */
37
+ const PUBLIC_SKILLS: SkillInfo[] = SKILLS_CATALOG.map(catalogEntryToSkillInfo);
38
+
39
+ class SkillsService {
40
+ static async getSkills(projectDir?: string): Promise<SkillInfo[]> {
41
+ if (getActiveBackend().backend.kind === "cloud") {
42
+ return fetchCloudSkills();
43
+ }
44
+
45
+ // Public skills come from the bundled @openhands/extensions npm package —
46
+ // no agent-server round-trip or GitHub fetch needed. Only ask the agent-
47
+ // server for user and project skills so local .agents/skills/ content is
48
+ // still picked up.
49
+ let localSkills: SkillInfo[] = [];
50
+ try {
51
+ const response = await new SkillsClient(
52
+ getAgentServerClientOptions(),
53
+ ).getSkills({
54
+ load_public: false,
55
+ load_user: true,
56
+ load_project: true,
57
+ load_org: false,
58
+ project_dir: projectDir ?? getAgentServerWorkingDir(),
59
+ });
60
+ localSkills = (response.skills ?? []) as SkillInfo[];
61
+ } catch {
62
+ // Agent-server may not support the skills endpoint or may be
63
+ // unreachable; fall back to the bundled public catalog alone.
64
+ }
65
+
66
+ return [...localSkills, ...PUBLIC_SKILLS];
67
+ }
68
+
69
+ /**
70
+ * Skills loaded into a running cloud conversation (see
71
+ * `fetchCloudConversationSkills`). Cloud-only: local conversations keep
72
+ * using `getSkills(projectDir)`, whose agent-server call already scopes to
73
+ * the conversation's workspace.
74
+ */
75
+ static getConversationSkills(conversationId: string): Promise<SkillInfo[]> {
76
+ return fetchCloudConversationSkills(conversationId);
77
+ }
78
+ }
79
+
80
+ export default SkillsService;
src/api/with-retry.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Retry helper for API calls with exponential backoff.
3
+ */
4
+ export async function withRetry<T>(
5
+ fn: () => Promise<T>,
6
+ maxRetries: number = 3,
7
+ baseDelayMs: number = 500,
8
+ ): Promise<T> {
9
+ for (let attempt = 0; attempt < maxRetries; attempt += 1) {
10
+ try {
11
+ return await fn();
12
+ } catch (error) {
13
+ if (attempt >= maxRetries - 1) {
14
+ throw error;
15
+ }
16
+
17
+ const delay = baseDelayMs * 2 ** attempt;
18
+
19
+ await new Promise<void>((resolve) => {
20
+ setTimeout(resolve, delay);
21
+ });
22
+ }
23
+ }
24
+
25
+ throw new Error("Retry attempts exhausted");
26
+ }
src/api/workspace-path-resolution.test.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ fileClientConstructorMock,
5
+ getAgentServerClientOptionsMock,
6
+ getHomeMock,
7
+ } = vi.hoisted(() => ({
8
+ fileClientConstructorMock: vi.fn(),
9
+ getAgentServerClientOptionsMock: vi.fn(),
10
+ getHomeMock: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@openhands/typescript-client/clients", () => ({
14
+ FileClient: class {
15
+ constructor(options: unknown) {
16
+ fileClientConstructorMock(options);
17
+ }
18
+
19
+ getHome() {
20
+ return getHomeMock();
21
+ }
22
+ },
23
+ }));
24
+
25
+ vi.mock("./agent-server-client-options", () => ({
26
+ getAgentServerClientOptions: getAgentServerClientOptionsMock,
27
+ }));
28
+
29
+ import {
30
+ clearAgentServerHomeDirCache,
31
+ getAgentServerHomeDir,
32
+ resolveAbsoluteAgentServerPath,
33
+ } from "./agent-server-home";
34
+
35
+ const defaultClientOptions = {
36
+ host: "https://agent.example.com",
37
+ apiKey: "agent-key",
38
+ workingDir: "workspace/project",
39
+ };
40
+
41
+ function arrangeHomeLookup(): void {
42
+ clearAgentServerHomeDirCache();
43
+ vi.resetAllMocks();
44
+ getAgentServerClientOptionsMock.mockReturnValue(defaultClientOptions);
45
+ }
46
+
47
+ describe("agent-server workspace path resolution", () => {
48
+ it("looks up the server home with the resolved client options and normalizes its separator", async () => {
49
+ arrangeHomeLookup();
50
+ getHomeMock.mockResolvedValue({ home: "/Users/alice///" });
51
+ const overrides = {
52
+ conversationUrl: "wss://runtime.example.com/conversations/1",
53
+ sessionApiKey: "session-key",
54
+ };
55
+
56
+ await expect(getAgentServerHomeDir(overrides)).resolves.toBe(
57
+ "/Users/alice",
58
+ );
59
+
60
+ expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides);
61
+ expect(fileClientConstructorMock).toHaveBeenCalledWith(
62
+ defaultClientOptions,
63
+ );
64
+ expect(getHomeMock).toHaveBeenCalledOnce();
65
+ });
66
+
67
+ it("shares one in-flight home lookup between callers for the same host", async () => {
68
+ arrangeHomeLookup();
69
+ getHomeMock.mockResolvedValue({ home: "/home/agent" });
70
+
71
+ await expect(
72
+ Promise.all([getAgentServerHomeDir(), getAgentServerHomeDir()]),
73
+ ).resolves.toEqual(["/home/agent", "/home/agent"]);
74
+
75
+ expect(getHomeMock).toHaveBeenCalledOnce();
76
+ });
77
+
78
+ it("caches home directories independently for each agent-server host", async () => {
79
+ arrangeHomeLookup();
80
+ getAgentServerClientOptionsMock.mockImplementation(
81
+ (overrides: { host?: string } = {}) => ({
82
+ ...defaultClientOptions,
83
+ host: overrides.host ?? defaultClientOptions.host,
84
+ }),
85
+ );
86
+ getHomeMock
87
+ .mockResolvedValueOnce({ home: "/home/first" })
88
+ .mockResolvedValueOnce({ home: "/home/second" });
89
+
90
+ await expect(
91
+ Promise.all([
92
+ getAgentServerHomeDir({ host: "https://first.example.com" }),
93
+ getAgentServerHomeDir({ host: "https://second.example.com" }),
94
+ ]),
95
+ ).resolves.toEqual(["/home/first", "/home/second"]);
96
+ await expect(
97
+ getAgentServerHomeDir({ host: "https://first.example.com" }),
98
+ ).resolves.toBe("/home/first");
99
+
100
+ expect(getHomeMock).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ it("retries a home lookup after a transient failure", async () => {
104
+ arrangeHomeLookup();
105
+ getHomeMock
106
+ .mockRejectedValueOnce(new Error("agent server unavailable"))
107
+ .mockResolvedValueOnce({ home: "/home/recovered" });
108
+
109
+ await expect(getAgentServerHomeDir()).rejects.toThrow(
110
+ "agent server unavailable",
111
+ );
112
+ await expect(getAgentServerHomeDir()).resolves.toBe("/home/recovered");
113
+
114
+ expect(getHomeMock).toHaveBeenCalledTimes(2);
115
+ });
116
+
117
+ it.each([undefined, null, "", 0, 42])(
118
+ "rejects an invalid home directory response (%s)",
119
+ async (home) => {
120
+ arrangeHomeLookup();
121
+ getHomeMock.mockResolvedValue({ home });
122
+
123
+ await expect(getAgentServerHomeDir()).rejects.toThrow(
124
+ "Agent server returned an empty home directory",
125
+ );
126
+ },
127
+ );
128
+
129
+ it("clears a successful cached lookup on request", async () => {
130
+ arrangeHomeLookup();
131
+ getHomeMock
132
+ .mockResolvedValueOnce({ home: "/home/first" })
133
+ .mockResolvedValueOnce({ home: "/home/refreshed" });
134
+
135
+ await expect(getAgentServerHomeDir()).resolves.toBe("/home/first");
136
+ clearAgentServerHomeDirCache();
137
+ await expect(getAgentServerHomeDir()).resolves.toBe("/home/refreshed");
138
+
139
+ expect(getHomeMock).toHaveBeenCalledTimes(2);
140
+ });
141
+
142
+ it.each([
143
+ ["/workspace/project///", "/workspace/project"],
144
+ ["C:\\workspace\\project\\", "C:\\workspace\\project"],
145
+ ["D:/workspace/project///", "D:/workspace/project"],
146
+ ["\\\\server\\share\\", "\\\\server\\share"],
147
+ ])(
148
+ "keeps the absolute path %s without a home lookup",
149
+ async (path, expected) => {
150
+ arrangeHomeLookup();
151
+
152
+ await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe(
153
+ expected,
154
+ );
155
+
156
+ expect(getAgentServerClientOptionsMock).not.toHaveBeenCalled();
157
+ expect(getHomeMock).not.toHaveBeenCalled();
158
+ },
159
+ );
160
+
161
+ it.each(["", "/", "\\", "///", "\\\\"])(
162
+ "resolves an empty path (%s) to the server home",
163
+ async (path) => {
164
+ arrangeHomeLookup();
165
+ getHomeMock.mockResolvedValue({ home: "/home/agent/" });
166
+
167
+ await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe(
168
+ "/home/agent",
169
+ );
170
+
171
+ expect(getHomeMock).toHaveBeenCalledOnce();
172
+ },
173
+ );
174
+
175
+ it("anchors a relative workspace below the server home", async () => {
176
+ arrangeHomeLookup();
177
+ getHomeMock.mockResolvedValue({ home: "/Users/alice///" });
178
+ const overrides = {
179
+ host: "https://runtime.example.com/",
180
+ sessionApiKey: "session-key",
181
+ };
182
+
183
+ await expect(
184
+ resolveAbsoluteAgentServerPath("workspace/project///", overrides),
185
+ ).resolves.toBe("/Users/alice/workspace/project");
186
+
187
+ expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides);
188
+ });
189
+ });
src/api/workspace-upload-path.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @spec WUP-001 — Resolve relative working dirs against /api/file/home
2
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
3
+ import { getAgentServerWorkingDir } from "#/api/agent-server-config";
4
+ import {
5
+ type AgentServerClientOverrides,
6
+ getAgentServerClientOptions,
7
+ } from "#/api/agent-server-client-options";
8
+ import { resolveAbsoluteAgentServerPath } from "#/api/agent-server-home";
9
+ import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";
10
+ import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
11
+
12
+ const UUID_PATTERN =
13
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
14
+
15
+ export function getSafeUploadFileName(fileName: string): string {
16
+ const parts = fileName.split(/[\\/]+/).filter(Boolean);
17
+ const safeName = parts[parts.length - 1];
18
+
19
+ if (!safeName || safeName === "." || safeName === "..") {
20
+ throw new Error("Invalid file name");
21
+ }
22
+
23
+ return safeName;
24
+ }
25
+
26
+ /**
27
+ * Resolve `workingDir` to an absolute path the agent-server's file APIs
28
+ * accept. Relative paths are joined against `/api/file/home` (cached per
29
+ * backend); absolute paths pass through.
30
+ */
31
+ export async function resolveAbsoluteWorkspacePath(
32
+ workingDir: string,
33
+ overrides: AgentServerClientOverrides = {},
34
+ ): Promise<string> {
35
+ return resolveAbsoluteAgentServerPath(workingDir, overrides);
36
+ }
37
+
38
+ /**
39
+ * Build the absolute destination path for a file upload, resolving the
40
+ * working-dir leg via {@link resolveAbsoluteWorkspacePath}.
41
+ */
42
+ export async function buildWorkspaceUploadPath(
43
+ fileName: string,
44
+ workingDir: string,
45
+ overrides: AgentServerClientOverrides = {},
46
+ ): Promise<string> {
47
+ const safeName = getSafeUploadFileName(fileName);
48
+ const absoluteDir = await resolveAbsoluteWorkspacePath(workingDir, overrides);
49
+ return `${absoluteDir.replace(/[/\\]+$/, "")}/${safeName}`;
50
+ }
51
+
52
+ /**
53
+ * Resolve the working directory for a file upload into a conversation's
54
+ * workspace.
55
+ *
56
+ * **Returns the raw working dir string** — which may be relative (e.g.
57
+ * `workspace/project/<hex>`) when the default `DEFAULT_WORKING_DIR` is in
58
+ * use. Callers that need an actual filesystem-absolute path (e.g. to pass to
59
+ * the agent-server's `/api/file/upload` endpoint) **must** funnel this result
60
+ * through {@link buildWorkspaceUploadPath}, which calls
61
+ * {@link resolveAbsoluteWorkspacePath} to anchor any relative segment against
62
+ * the agent-server's home directory.
63
+ *
64
+ * Why not resolve here? Because this function is also called by cloud-runtime
65
+ * upload paths where the overrides (conversationUrl, sessionApiKey) aren't
66
+ * available until {@link uploadFilesToConversation} assembles them. Keeping
67
+ * the resolution step in {@link buildWorkspaceUploadPath} means both the
68
+ * local and cloud legs share a single resolution point with the correct
69
+ * override context.
70
+ */
71
+ export async function resolveConversationUploadWorkingDir(
72
+ conversationId: string,
73
+ currentConversation?: AppConversation | null,
74
+ ): Promise<string> {
75
+ if (
76
+ currentConversation?.id === conversationId &&
77
+ currentConversation.workspace?.working_dir?.trim()
78
+ ) {
79
+ return currentConversation.workspace.working_dir.trim();
80
+ }
81
+
82
+ const stored = getStoredConversationMetadata(conversationId);
83
+ if (stored?.selected_workspace?.trim()) {
84
+ return stored.selected_workspace.trim();
85
+ }
86
+
87
+ if (UUID_PATTERN.test(conversationId)) {
88
+ return AgentServerConversationService.resolveConversationWorkingDir(
89
+ conversationId,
90
+ );
91
+ }
92
+
93
+ return getAgentServerWorkingDir();
94
+ }
95
+
96
+ // Re-export so callers can construct overrides matching what
97
+ // {@link buildWorkspaceUploadPath} expects without importing two modules.
98
+ export type { AgentServerClientOverrides };
99
+ export { getAgentServerClientOptions };
src/dev/seed-pending-chat-preview.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
2
+
3
+ const PREVIEW_SENDING_TEXT = "can you review the readme";
4
+
5
+ const PREVIEW_SENDING_MULTILINE_TEXT =
6
+ "can you review the readme and then update the contributing guide " +
7
+ "to match, making sure the setup steps and scripts are consistent " +
8
+ "across both files";
9
+
10
+ const PREVIEW_ERROR_TEXT =
11
+ "here's a long message. here's a long message. here's a long message. " +
12
+ "here's a long message. here's a long message. here's a long message.";
13
+
14
+ export function seedPendingChatPreview(conversationId: string): void {
15
+ const store = useOptimisticUserMessageStore.getState();
16
+ store.clearPendingMessages();
17
+
18
+ store.enqueuePendingMessage({
19
+ conversationId,
20
+ text: PREVIEW_SENDING_TEXT,
21
+ });
22
+
23
+ store.enqueuePendingMessage({
24
+ conversationId,
25
+ text: PREVIEW_SENDING_MULTILINE_TEXT,
26
+ });
27
+
28
+ const errorId = store.enqueuePendingMessage({
29
+ conversationId,
30
+ text: PREVIEW_ERROR_TEXT,
31
+ });
32
+ store.markPendingMessageError(errorId, "Failed to send message");
33
+ }
34
+
35
+ export function installPendingChatPreview(): void {
36
+ (
37
+ window as Window &
38
+ typeof globalThis & {
39
+ __OH_SEED_PENDING_PREVIEW__?: typeof seedPendingChatPreview;
40
+ __OH_CLEAR_PENDING_PREVIEW__?: () => void;
41
+ }
42
+ ).__OH_SEED_PENDING_PREVIEW__ = seedPendingChatPreview;
43
+ (
44
+ window as Window &
45
+ typeof globalThis & {
46
+ __OH_CLEAR_PENDING_PREVIEW__?: () => void;
47
+ }
48
+ ).__OH_CLEAR_PENDING_PREVIEW__ = () => {
49
+ useOptimisticUserMessageStore.getState().clearPendingMessages();
50
+ };
51
+
52
+ const params = new URLSearchParams(window.location.search);
53
+ const preview = params.get("previewPendingChat");
54
+ if (!preview) {
55
+ return;
56
+ }
57
+
58
+ const conversationId = preview === "true" ? "1" : preview;
59
+ const targetPath = `/conversations/${conversationId}`;
60
+
61
+ if (!window.location.pathname.startsWith(targetPath)) {
62
+ window.location.assign(
63
+ `${targetPath}?previewPendingChat=${encodeURIComponent(conversationId)}`,
64
+ );
65
+ return;
66
+ }
67
+
68
+ seedPendingChatPreview(conversationId);
69
+ }
src/fixtures/canvas-demo-conversation.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated-canvas demo fixture — models an agent creating a Markdown artifact
3
+ * so mock mode can demonstrate chat-to-drawer file navigation without an LLM.
4
+ */
5
+ import type {
6
+ ActionEvent,
7
+ MessageEvent,
8
+ ObservationEvent,
9
+ OpenHandsEvent,
10
+ } from "#/types/agent-server/core";
11
+ import { SecurityRisk } from "#/types/agent-server/core";
12
+ import type { FileEditorAction } from "#/types/agent-server/core/base/action";
13
+ import type { FileEditorObservation } from "#/types/agent-server/core/base/observation";
14
+
15
+ export const CANVAS_DEMO_CONVERSATION_ID = "canvas-demo";
16
+ export const CANVAS_DEMO_FILE_PATH = "canvas.md";
17
+
18
+ export const CANVAS_DEMO_MARKDOWN = [
19
+ "# Agent Canvas Demo",
20
+ "",
21
+ "This report was generated by the agent and opened from the file chip in chat.",
22
+ "",
23
+ "## Release snapshot",
24
+ "",
25
+ "| Area | Status | Next step |",
26
+ "| --- | --- | --- |",
27
+ "| Conversation UX | Ready | Validate the drawer flow |",
28
+ "| Markdown preview | Ready | Review rich rendering |",
29
+ "| Accessibility | Ready | Confirm keyboard navigation |",
30
+ "",
31
+ "## Highlights",
32
+ "",
33
+ "- The artifact stays in the conversation workspace.",
34
+ "- The chat chip is the compact entry point.",
35
+ "- The Files drawer provides the full rich Markdown view.",
36
+ ].join("\n");
37
+
38
+ const BASE_TIME = Date.UTC(2026, 6, 29, 12, 0, 0);
39
+ const timestamp = (offsetSeconds: number) =>
40
+ new Date(BASE_TIME + offsetSeconds * 1000).toISOString();
41
+
42
+ /**
43
+ * Creates a minimal message event with the same SDK shape used by history.
44
+ */
45
+ function createMessage(
46
+ id: string,
47
+ role: "user" | "assistant",
48
+ text: string,
49
+ offsetSeconds: number,
50
+ ): MessageEvent {
51
+ return {
52
+ id,
53
+ timestamp: timestamp(offsetSeconds),
54
+ source: role === "user" ? "user" : "agent",
55
+ llm_message: {
56
+ role,
57
+ content: [{ type: "text", text }],
58
+ },
59
+ activated_skills: [],
60
+ extended_content: [],
61
+ };
62
+ }
63
+
64
+ const createCanvasAction: ActionEvent<FileEditorAction> = {
65
+ id: "canvas-demo-action",
66
+ timestamp: timestamp(1),
67
+ source: "agent",
68
+ thought: [],
69
+ thinking_blocks: [],
70
+ action: {
71
+ kind: "FileEditorAction",
72
+ command: "create",
73
+ path: CANVAS_DEMO_FILE_PATH,
74
+ file_text: CANVAS_DEMO_MARKDOWN,
75
+ old_str: null,
76
+ new_str: null,
77
+ insert_line: null,
78
+ view_range: null,
79
+ },
80
+ tool_name: "file_editor",
81
+ tool_call_id: "canvas-demo-tool-call",
82
+ tool_call: {
83
+ id: "canvas-demo-tool-call",
84
+ type: "function",
85
+ function: {
86
+ name: "file_editor",
87
+ arguments: JSON.stringify({
88
+ command: "create",
89
+ path: CANVAS_DEMO_FILE_PATH,
90
+ }),
91
+ },
92
+ },
93
+ llm_response_id: "canvas-demo-response",
94
+ security_risk: SecurityRisk.LOW,
95
+ };
96
+
97
+ const createCanvasObservation: ObservationEvent<FileEditorObservation> = {
98
+ id: "canvas-demo-observation",
99
+ timestamp: timestamp(2),
100
+ source: "environment",
101
+ tool_name: "file_editor",
102
+ tool_call_id: "canvas-demo-tool-call",
103
+ action_id: createCanvasAction.id,
104
+ observation: {
105
+ kind: "FileEditorObservation",
106
+ command: "create",
107
+ output: `Created ${CANVAS_DEMO_FILE_PATH}`,
108
+ path: CANVAS_DEMO_FILE_PATH,
109
+ prev_exist: false,
110
+ old_content: null,
111
+ new_content: CANVAS_DEMO_MARKDOWN,
112
+ error: null,
113
+ },
114
+ };
115
+
116
+ export const CANVAS_DEMO_EVENTS: OpenHandsEvent[] = [
117
+ createMessage(
118
+ "canvas-demo-user",
119
+ "user",
120
+ "Create a concise project canvas as a Markdown report.",
121
+ 0,
122
+ ),
123
+ createCanvasAction,
124
+ createCanvasObservation,
125
+ createMessage(
126
+ "canvas-demo-agent",
127
+ "assistant",
128
+ "I generated the project canvas. Open it from the file chip above.",
129
+ 3,
130
+ ),
131
+ ];
src/fixtures/home-automations-demo.ts ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AUTOMATION_RUN_ACTIVITY_LIMIT,
3
+ type LatestAutomationRunState,
4
+ } from "#/hooks/query/use-latest-automation-runs";
5
+ import {
6
+ AutomationRunStatus,
7
+ type Automation,
8
+ type AutomationRun,
9
+ } from "#/types/automation";
10
+
11
+ /** Must stay equal to `MAX_HOME_AUTOMATION_CHIPS` in use-home-automations. */
12
+ const DEMO_LIST_LENGTH = 20;
13
+
14
+ const DEMO_HISTORY_PATTERN: AutomationRunStatus[] = [
15
+ AutomationRunStatus.COMPLETED,
16
+ AutomationRunStatus.COMPLETED,
17
+ AutomationRunStatus.FAILED,
18
+ AutomationRunStatus.COMPLETED,
19
+ AutomationRunStatus.CANCELLED,
20
+ AutomationRunStatus.COMPLETED,
21
+ AutomationRunStatus.COMPLETED,
22
+ AutomationRunStatus.FAILED,
23
+ AutomationRunStatus.COMPLETED,
24
+ AutomationRunStatus.SKIPPED,
25
+ AutomationRunStatus.COMPLETED,
26
+ ];
27
+
28
+ /**
29
+ * Local preview of every pinned-card + activity-list state.
30
+ *
31
+ * Enable with either:
32
+ * - `HOME_AUTOMATIONS_FORCE_DEMO = true` below
33
+ * - `VITE_HOME_AUTOMATIONS_DEMO=true`
34
+ */
35
+ export const HOME_AUTOMATIONS_FORCE_DEMO = false;
36
+
37
+ export const HOME_AUTOMATIONS_DEMO_KEY = "oh:home-automations-demo";
38
+
39
+ export function isHomeAutomationsDemoEnabled(): boolean {
40
+ if (HOME_AUTOMATIONS_FORCE_DEMO) return true;
41
+ return import.meta.env.VITE_HOME_AUTOMATIONS_DEMO === "true";
42
+ }
43
+
44
+ const DEMO_CONVERSATION_TITLES: Record<string, string | null> = {
45
+ "demo-conv-titled": "Fix flaky CI on main",
46
+ "demo-conv-untitled": "Summarize open pull requests",
47
+ };
48
+
49
+ /** Returns a demo conversation title, or `undefined` if not a demo id. */
50
+ export function getDemoConversationTitle(
51
+ conversationId: string,
52
+ ): string | null | undefined {
53
+ if (!(conversationId in DEMO_CONVERSATION_TITLES)) return undefined;
54
+ return DEMO_CONVERSATION_TITLES[conversationId];
55
+ }
56
+
57
+ function hoursAgo(hours: number): string {
58
+ return new Date(Date.now() - hours * 3_600_000).toISOString();
59
+ }
60
+
61
+ function makeAutomation(
62
+ id: string,
63
+ name: string,
64
+ trigger: Automation["trigger"],
65
+ ): Automation {
66
+ return {
67
+ id,
68
+ name,
69
+ trigger,
70
+ enabled: true,
71
+ created_at: hoursAgo(720),
72
+ updated_at: hoursAgo(24),
73
+ prompt: "demo",
74
+ };
75
+ }
76
+
77
+ function makeRun(
78
+ id: string,
79
+ status: AutomationRunStatus,
80
+ options: {
81
+ conversationId?: string | null;
82
+ errorDetail?: string | null;
83
+ startedHoursAgo?: number;
84
+ completedHoursAgo?: number | null;
85
+ } = {},
86
+ ): AutomationRun {
87
+ const startedHoursAgo = options.startedHoursAgo ?? 2;
88
+ const completedHoursAgo =
89
+ options.completedHoursAgo === undefined ? 1 : options.completedHoursAgo;
90
+ return {
91
+ id,
92
+ status,
93
+ conversation_id:
94
+ options.conversationId === undefined ? null : options.conversationId,
95
+ bash_command_id: null,
96
+ error_detail:
97
+ options.errorDetail === undefined ? null : options.errorDetail,
98
+ started_at: hoursAgo(startedHoursAgo),
99
+ completed_at:
100
+ completedHoursAgo === null ? null : hoursAgo(completedHoursAgo),
101
+ };
102
+ }
103
+
104
+ type DemoRunStateInput = Omit<LatestAutomationRunState, "recentRuns"> & {
105
+ recentRuns?: AutomationRun[];
106
+ };
107
+
108
+ type DemoSpec = {
109
+ automation: Automation;
110
+ runState: DemoRunStateInput;
111
+ /** When true, this automation is pinned in the dashboard. */
112
+ pin?: boolean;
113
+ };
114
+
115
+ function buildDemoRecentRuns(latestRun: AutomationRun | null): AutomationRun[] {
116
+ if (!latestRun) return [];
117
+ // Varied durations (minutes) so sparkline bar heights differ in demo mode.
118
+ const durationMinutes = [2, 8, 45, 3, 15, 1, 25, 90, 5, 12, 6];
119
+ const older = DEMO_HISTORY_PATTERN.slice(
120
+ 0,
121
+ AUTOMATION_RUN_ACTIVITY_LIMIT - 1,
122
+ ).map((status, index) => {
123
+ const startedHoursAgo = 24 + index * 6;
124
+ const durationHours = (durationMinutes[index] ?? 5) / 60;
125
+ return makeRun(`${latestRun.id}-hist-${index}`, status, {
126
+ startedHoursAgo,
127
+ completedHoursAgo: startedHoursAgo - durationHours,
128
+ conversationId: null,
129
+ errorDetail:
130
+ status === AutomationRunStatus.FAILED ? "Prior failure" : null,
131
+ });
132
+ });
133
+ return [latestRun, ...older];
134
+ }
135
+
136
+ function toDemoRunState(state: DemoRunStateInput): LatestAutomationRunState {
137
+ return {
138
+ ...state,
139
+ recentRuns: state.recentRuns ?? buildDemoRecentRuns(state.latestRun),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Explicit card states first (pinned), then fillers to hit
145
+ * DEMO_LIST_LENGTH for list-length stress.
146
+ */
147
+ const DEMO_SPECS: DemoSpec[] = [
148
+ {
149
+ pin: true,
150
+ automation: makeAutomation("demo-pin-loading", "Card: loading skeleton", {
151
+ type: "cron",
152
+ schedule: "0 * * * *",
153
+ schedule_human: "Every hour",
154
+ }),
155
+ runState: { latestRun: null, isLoading: true, isError: false },
156
+ },
157
+ {
158
+ pin: true,
159
+ automation: makeAutomation("demo-pin-error", "Card: status unavailable", {
160
+ type: "cron",
161
+ schedule: "0 9 * * *",
162
+ schedule_human: "Daily at 9am",
163
+ }),
164
+ runState: { latestRun: null, isLoading: false, isError: true },
165
+ },
166
+ {
167
+ pin: true,
168
+ automation: makeAutomation("demo-pin-no-runs", "Card: no runs yet", {
169
+ type: "event",
170
+ source: "github",
171
+ on: "pull_request.opened",
172
+ }),
173
+ runState: { latestRun: null, isLoading: false, isError: false },
174
+ },
175
+ {
176
+ pin: true,
177
+ automation: makeAutomation(
178
+ "demo-pin-failed-detail",
179
+ "Card: failed with error detail",
180
+ {
181
+ type: "cron",
182
+ schedule: "*/15 * * * *",
183
+ schedule_human: "Every 15 min",
184
+ },
185
+ ),
186
+ runState: {
187
+ latestRun: makeRun("run-failed-detail", AutomationRunStatus.FAILED, {
188
+ errorDetail:
189
+ "Sandbox provisioning timed out after 120s while waiting for the runtime to become ready.",
190
+ startedHoursAgo: 3,
191
+ completedHoursAgo: 2.5,
192
+ }),
193
+ isLoading: false,
194
+ isError: false,
195
+ },
196
+ },
197
+ {
198
+ pin: true,
199
+ automation: makeAutomation(
200
+ "demo-pin-completed-title",
201
+ "Card: completed + conversation title",
202
+ { type: "event", source: "github", on: "push" },
203
+ ),
204
+ runState: {
205
+ latestRun: makeRun("run-completed-title", AutomationRunStatus.COMPLETED, {
206
+ conversationId: "demo-conv-titled",
207
+ startedHoursAgo: 5,
208
+ completedHoursAgo: 4,
209
+ }),
210
+ isLoading: false,
211
+ isError: false,
212
+ },
213
+ },
214
+ {
215
+ pin: true,
216
+ automation: makeAutomation(
217
+ "demo-pin-completed-untitled",
218
+ "Card: completed + conversation title",
219
+ {
220
+ type: "cron",
221
+ schedule: "0 0 * * 1",
222
+ schedule_human: "Mondays at midnight",
223
+ },
224
+ ),
225
+ runState: {
226
+ latestRun: makeRun(
227
+ "run-completed-untitled",
228
+ AutomationRunStatus.COMPLETED,
229
+ {
230
+ conversationId: "demo-conv-untitled",
231
+ startedHoursAgo: 8,
232
+ completedHoursAgo: 7,
233
+ },
234
+ ),
235
+ isLoading: false,
236
+ isError: false,
237
+ },
238
+ },
239
+ {
240
+ pin: true,
241
+ automation: makeAutomation(
242
+ "demo-pin-completed-no-conv",
243
+ "Card: completed, no conversation",
244
+ { type: "cron", schedule: "0 12 * * *", schedule_human: "Daily at noon" },
245
+ ),
246
+ runState: {
247
+ latestRun: makeRun(
248
+ "run-completed-no-conv",
249
+ AutomationRunStatus.COMPLETED,
250
+ {
251
+ conversationId: null,
252
+ startedHoursAgo: 10,
253
+ completedHoursAgo: 9,
254
+ },
255
+ ),
256
+ isLoading: false,
257
+ isError: false,
258
+ },
259
+ },
260
+ {
261
+ pin: true,
262
+ automation: makeAutomation(
263
+ "demo-pin-failed-no-conv",
264
+ "Card: failed, no conversation",
265
+ { type: "event", source: "slack", on: "message" },
266
+ ),
267
+ runState: {
268
+ latestRun: makeRun("run-failed-no-conv", AutomationRunStatus.FAILED, {
269
+ conversationId: null,
270
+ errorDetail: null,
271
+ startedHoursAgo: 12,
272
+ completedHoursAgo: 11,
273
+ }),
274
+ isLoading: false,
275
+ isError: false,
276
+ },
277
+ },
278
+ {
279
+ pin: true,
280
+ automation: makeAutomation(
281
+ "demo-pin-pending",
282
+ "Card: pending (empty result panel)",
283
+ {
284
+ type: "cron",
285
+ schedule: "0 */6 * * *",
286
+ schedule_human: "Every 6 hours",
287
+ },
288
+ ),
289
+ runState: {
290
+ latestRun: makeRun("run-pending", AutomationRunStatus.PENDING, {
291
+ conversationId: null,
292
+ startedHoursAgo: 0.1,
293
+ completedHoursAgo: null,
294
+ }),
295
+ isLoading: false,
296
+ isError: false,
297
+ },
298
+ },
299
+ {
300
+ pin: true,
301
+ automation: makeAutomation(
302
+ "demo-pin-running",
303
+ "Card: running (empty result panel)",
304
+ { type: "event", source: "github", on: "issues.opened" },
305
+ ),
306
+ runState: {
307
+ latestRun: makeRun("run-running", AutomationRunStatus.RUNNING, {
308
+ // No conversation yet — status strip shows the "Running" label.
309
+ conversationId: null,
310
+ startedHoursAgo: 0.25,
311
+ completedHoursAgo: null,
312
+ }),
313
+ isLoading: false,
314
+ isError: false,
315
+ },
316
+ },
317
+ // Remaining rows fill the list to MAX_HOME_AUTOMATION_CHIPS.
318
+ {
319
+ automation: makeAutomation("demo-list-cancelled", "List: cancelled run", {
320
+ type: "cron",
321
+ schedule: "30 2 * * *",
322
+ schedule_human: "Daily at 2:30am",
323
+ }),
324
+ runState: {
325
+ latestRun: makeRun("run-cancelled", AutomationRunStatus.CANCELLED, {
326
+ startedHoursAgo: 14,
327
+ completedHoursAgo: 13,
328
+ }),
329
+ isLoading: false,
330
+ isError: false,
331
+ },
332
+ },
333
+ {
334
+ automation: makeAutomation("demo-list-skipped", "List: skipped run", {
335
+ type: "event",
336
+ source: "github",
337
+ on: "pull_request.synchronize",
338
+ }),
339
+ runState: {
340
+ latestRun: makeRun("run-skipped", AutomationRunStatus.SKIPPED, {
341
+ startedHoursAgo: 16,
342
+ completedHoursAgo: 16,
343
+ }),
344
+ isLoading: false,
345
+ isError: false,
346
+ },
347
+ },
348
+ {
349
+ automation: makeAutomation(
350
+ "demo-list-running-2",
351
+ "List: second in-flight run",
352
+ { type: "cron", schedule: "*/5 * * * *", schedule_human: "Every 5 min" },
353
+ ),
354
+ runState: {
355
+ latestRun: makeRun("run-running-2", AutomationRunStatus.RUNNING, {
356
+ conversationId: null,
357
+ startedHoursAgo: 0.5,
358
+ completedHoursAgo: null,
359
+ }),
360
+ isLoading: false,
361
+ isError: false,
362
+ },
363
+ },
364
+ {
365
+ automation: makeAutomation("demo-list-success-old", "List: older success", {
366
+ type: "cron",
367
+ schedule: "0 8 * * 1-5",
368
+ schedule_human: "Weekdays at 8am",
369
+ }),
370
+ runState: {
371
+ latestRun: makeRun("run-success-old", AutomationRunStatus.COMPLETED, {
372
+ conversationId: "demo-conv-titled",
373
+ startedHoursAgo: 48,
374
+ completedHoursAgo: 47,
375
+ }),
376
+ isLoading: false,
377
+ isError: false,
378
+ },
379
+ },
380
+ {
381
+ automation: makeAutomation("demo-list-failed-old", "List: older failure", {
382
+ type: "event",
383
+ source: "github",
384
+ on: "release.published",
385
+ }),
386
+ runState: {
387
+ latestRun: makeRun("run-failed-old", AutomationRunStatus.FAILED, {
388
+ errorDetail: "Model returned an empty completion.",
389
+ startedHoursAgo: 72,
390
+ completedHoursAgo: 71,
391
+ }),
392
+ isLoading: false,
393
+ isError: false,
394
+ },
395
+ },
396
+ {
397
+ automation: makeAutomation("demo-list-no-runs-2", "List: never ran", {
398
+ type: "cron",
399
+ schedule: "0 0 1 * *",
400
+ schedule_human: "Monthly on the 1st",
401
+ }),
402
+ runState: { latestRun: null, isLoading: false, isError: false },
403
+ },
404
+ {
405
+ automation: makeAutomation("demo-list-loading-2", "List: loading status", {
406
+ type: "event",
407
+ source: "linear",
408
+ on: "Issue.create",
409
+ }),
410
+ runState: { latestRun: null, isLoading: true, isError: false },
411
+ },
412
+ {
413
+ automation: makeAutomation("demo-list-error-2", "List: fetch error", {
414
+ type: "cron",
415
+ schedule: "15 * * * *",
416
+ schedule_human: "Hourly at :15",
417
+ }),
418
+ runState: { latestRun: null, isLoading: false, isError: true },
419
+ },
420
+ {
421
+ automation: makeAutomation(
422
+ "demo-list-pending-2",
423
+ "List: pending, long name that should truncate in the activity row when the viewport is narrow",
424
+ { type: "cron", schedule: "0 3 * * 0", schedule_human: "Sundays at 3am" },
425
+ ),
426
+ runState: {
427
+ latestRun: makeRun("run-pending-2", AutomationRunStatus.PENDING, {
428
+ startedHoursAgo: 0.05,
429
+ completedHoursAgo: null,
430
+ }),
431
+ isLoading: false,
432
+ isError: false,
433
+ },
434
+ },
435
+ {
436
+ automation: makeAutomation(
437
+ "demo-list-completed-fallback",
438
+ "List: completed with conversation",
439
+ { type: "event", source: "github", on: "workflow_run.completed" },
440
+ ),
441
+ runState: {
442
+ latestRun: makeRun(
443
+ "run-completed-fallback",
444
+ AutomationRunStatus.COMPLETED,
445
+ {
446
+ conversationId: "demo-conv-untitled",
447
+ startedHoursAgo: 20,
448
+ completedHoursAgo: 19,
449
+ },
450
+ ),
451
+ isLoading: false,
452
+ isError: false,
453
+ },
454
+ },
455
+ ];
456
+
457
+ if (DEMO_SPECS.length !== DEMO_LIST_LENGTH) {
458
+ throw new Error(
459
+ `home-automations-demo: expected ${DEMO_LIST_LENGTH} specs, got ${DEMO_SPECS.length}`,
460
+ );
461
+ }
462
+
463
+ export const HOME_AUTOMATIONS_DEMO_PINNED_IDS: string[] = DEMO_SPECS.filter(
464
+ (spec) => spec.pin,
465
+ ).map((spec) => spec.automation.id);
466
+
467
+ export const HOME_AUTOMATIONS_DEMO_AUTOMATIONS: Automation[] = DEMO_SPECS.map(
468
+ (spec) => spec.automation,
469
+ );
470
+
471
+ export const HOME_AUTOMATIONS_DEMO_RUN_STATES: Map<
472
+ string,
473
+ LatestAutomationRunState
474
+ > = new Map(
475
+ DEMO_SPECS.map(
476
+ (spec) => [spec.automation.id, toDemoRunState(spec.runState)] as const,
477
+ ),
478
+ );
src/fixtures/table-demo-conversation.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core";
2
+
3
+ export const TABLE_DEMO_CONVERSATION_ID = "table-demo";
4
+
5
+ const TABLE_DEMO_USER_MESSAGE =
6
+ "Can you compare several coding agents in a wide table?";
7
+
8
+ /** Wide GFM table used to exercise horizontal scrolling in the chat feed. */
9
+ export const TABLE_DEMO_AGENT_MARKDOWN = [
10
+ "Here is a wide comparison table. Scroll horizontally to read every column:",
11
+ "",
12
+ "| Feature | OpenHands | Claude Code | Codex | Gemini CLI | Cursor | Windsurf | Cline | Aider | Devin | Continue |",
13
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
14
+ "| CLI | Yes | Yes | Yes | Yes | IDE | IDE | IDE | Terminal | Cloud | IDE |",
15
+ "| MCP support | Yes | Yes | Partial | Partial | Yes | Yes | Yes | No | Yes | Yes |",
16
+ "| Local models | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes |",
17
+ "| Browser tools | Yes | Yes | Yes | Limited | Yes | Yes | Yes | No | Yes | Yes |",
18
+ "| Git integration | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |",
19
+ "| Best for | General coding | Anthropic stack | OpenAI stack | Google stack | IDE users | IDE users | VS Code | Terminal | Autonomous | IDE users |",
20
+ ].join("\n");
21
+
22
+ const TABLE_DEMO_BASE_TIME = Date.UTC(2026, 5, 23, 12, 0, 0);
23
+
24
+ function createTableDemoMessageEvent(
25
+ id: string,
26
+ role: "user" | "assistant",
27
+ text: string,
28
+ offsetMinutes: number,
29
+ ): MessageEvent {
30
+ return {
31
+ id,
32
+ timestamp: new Date(
33
+ TABLE_DEMO_BASE_TIME + offsetMinutes * 60_000,
34
+ ).toISOString(),
35
+ source: role === "user" ? "user" : "agent",
36
+ llm_message: {
37
+ role,
38
+ content: [{ type: "text", text }],
39
+ },
40
+ activated_skills: [],
41
+ extended_content: [],
42
+ };
43
+ }
44
+
45
+ export const TABLE_DEMO_EVENTS: OpenHandsEvent[] = [
46
+ createTableDemoMessageEvent(
47
+ "table-demo-user-1",
48
+ "user",
49
+ TABLE_DEMO_USER_MESSAGE,
50
+ 0,
51
+ ),
52
+ createTableDemoMessageEvent(
53
+ "table-demo-agent-1",
54
+ "assistant",
55
+ TABLE_DEMO_AGENT_MARKDOWN,
56
+ 1,
57
+ ),
58
+ ];
src/hooks/use-acp-model-context.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useTranslation } from "react-i18next";
2
+ import { useActiveBackend } from "#/contexts/active-backend-context";
3
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
4
+ import { useSettings } from "#/hooks/query/use-settings";
5
+ import { useActiveAgentKind } from "#/hooks/use-active-agent-profile";
6
+ import { I18nKey } from "#/i18n/declaration";
7
+
8
+ const AGENT_SETTINGS_PATH = "/settings/agents";
9
+ const LLM_SETTINGS_PATH = "/settings/llm";
10
+
11
+ export interface AcpModelContext {
12
+ /** The active conversation runs an ACP agent. */
13
+ isActiveAcpConversation: boolean;
14
+ /**
15
+ * No active conversation (home page) but the saved agent settings already
16
+ * select an ACP agent — the next conversation created here inherits it, so
17
+ * the model UI should reflect that now.
18
+ */
19
+ isHomeAcp: boolean;
20
+ /** Either of the above: the model affordance should defer to the ACP picker. */
21
+ isAcpContext: boolean;
22
+ /** Where the model/settings link should navigate. */
23
+ destinationPath: typeof AGENT_SETTINGS_PATH | typeof LLM_SETTINGS_PATH;
24
+ /** Translated label for that link. */
25
+ destinationLabel: string;
26
+ }
27
+
28
+ /**
29
+ * Shared source of truth for "is this an ACP model context, and where does
30
+ * the model affordance link?". The chat model affordance consumes this through
31
+ * ``useChatInputModelState`` so inline and overflow surfaces can't drift on the
32
+ * home-page-ACP rule or on the destination path/label.
33
+ */
34
+ export function useAcpModelContext(): AcpModelContext {
35
+ const { t } = useTranslation("openhands");
36
+ const { backend } = useActiveBackend();
37
+ const { data: conversation } = useActiveConversation();
38
+ const { data: settings } = useSettings();
39
+ const activeAgentKind = useActiveAgentKind();
40
+
41
+ const isActiveAcpConversation = conversation?.agent_kind === "acp";
42
+ // On the home page the "current agent" is the active AgentProfile, not the
43
+ // global agent settings (activate never writes them). Fall back to settings
44
+ // only while the profile list is loading.
45
+ const isHomeAcp =
46
+ !conversation &&
47
+ (activeAgentKind ?? settings?.agent_settings?.agent_kind) === "acp";
48
+ const isAcpContext = isActiveAcpConversation || isHomeAcp;
49
+
50
+ const destinationPath = isAcpContext
51
+ ? AGENT_SETTINGS_PATH
52
+ : LLM_SETTINGS_PATH;
53
+ const destinationLabel = t(
54
+ isAcpContext
55
+ ? I18nKey.SETTINGS$NAV_AGENT
56
+ : backend.kind === "cloud"
57
+ ? I18nKey.SETTINGS$LLM_SETTINGS
58
+ : I18nKey.SETTINGS$LLM_PROFILES,
59
+ );
60
+
61
+ return {
62
+ isActiveAcpConversation,
63
+ isHomeAcp,
64
+ isAcpContext,
65
+ destinationPath,
66
+ destinationLabel,
67
+ };
68
+ }
src/hooks/use-agent-notification.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { AgentState } from "#/types/agent-state";
3
+ import { useSettings } from "#/hooks/query/use-settings";
4
+ import notificationSound from "#/assets/notification.mp3";
5
+
6
+ const NOTIFICATION_STATES: AgentState[] = [
7
+ AgentState.AWAITING_USER_INPUT,
8
+ AgentState.FINISHED,
9
+ AgentState.AWAITING_USER_CONFIRMATION,
10
+ ];
11
+
12
+ /**
13
+ * Hook that plays a notification sound when the agent transitions into a
14
+ * state that requires user attention. The browser tab title itself is
15
+ * managed by `useAppTitle`, which prefixes the title with an emoji that
16
+ * reflects the current agent state.
17
+ */
18
+ export function useAgentNotification(curAgentState: AgentState) {
19
+ const { data: settings } = useSettings();
20
+ const audioRef = useRef<HTMLAudioElement | undefined>(undefined);
21
+ const prevStateRef = useRef<AgentState | undefined>(undefined);
22
+
23
+ // Initialize audio only in browser environment, inside useEffect to
24
+ // avoid side effects during render (React 18 strict mode, SSR safety).
25
+ useEffect(() => {
26
+ if (typeof window !== "undefined" && !audioRef.current) {
27
+ audioRef.current = new Audio(notificationSound);
28
+ audioRef.current.volume = 0.5;
29
+ }
30
+ }, []);
31
+
32
+ const isSoundEnabled = settings?.enable_sound_notifications ?? false;
33
+
34
+ // Trigger notification only on actual state transitions into a
35
+ // notification-worthy state — not when unrelated deps (e.g. settings) change.
36
+ useEffect(() => {
37
+ if (prevStateRef.current === curAgentState) return;
38
+ prevStateRef.current = curAgentState;
39
+
40
+ if (!NOTIFICATION_STATES.includes(curAgentState)) return;
41
+
42
+ if (isSoundEnabled && audioRef.current) {
43
+ audioRef.current.currentTime = 0;
44
+ audioRef.current.play().catch(() => {
45
+ // Ignore autoplay errors (browsers may block autoplay)
46
+ });
47
+ }
48
+ }, [curAgentState, isSoundEnabled]);
49
+ }
src/hooks/use-app-title.test.tsx ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { useParams } from "react-router";
5
+ import { useUserConversation } from "./query/use-user-conversation";
6
+ import { useAppTitle } from "./use-app-title";
7
+ import { useConversationStateStore } from "#/stores/conversation-state-store";
8
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
9
+
10
+ const renderAppTitleHook = () =>
11
+ renderHook(() => useAppTitle(), {
12
+ wrapper: ({ children }) => (
13
+ <QueryClientProvider client={new QueryClient()}>
14
+ {children}
15
+ </QueryClientProvider>
16
+ ),
17
+ });
18
+
19
+ vi.mock("./query/use-user-conversation");
20
+ vi.mock("react-router", async () => {
21
+ const actual = await vi.importActual("react-router");
22
+ return {
23
+ ...actual,
24
+ useParams: vi.fn(),
25
+ };
26
+ });
27
+
28
+ describe("useAppTitle", () => {
29
+ const mockUseUserConversation = vi.mocked(useUserConversation);
30
+ const mockUseParams = vi.mocked(useParams);
31
+
32
+ beforeEach(() => {
33
+ // @ts-expect-error - only returning partial config for test
34
+ mockUseUserConversation.mockReturnValue({ data: null });
35
+ mockUseParams.mockReturnValue({});
36
+ useConversationStateStore.getState().reset();
37
+ });
38
+
39
+ it("returns the OSS app title outside conversations", async () => {
40
+ const { result } = renderAppTitleHook();
41
+
42
+ await waitFor(() => expect(result.current).toBe("OpenHands"));
43
+ });
44
+
45
+ it("returns the conversation title with the OSS app name", async () => {
46
+ mockUseParams.mockReturnValue({ conversationId: "123" });
47
+ mockUseUserConversation.mockReturnValue({
48
+ // @ts-expect-error - only returning partial config for test
49
+ data: { title: "My Conversation" },
50
+ });
51
+
52
+ const { result } = renderAppTitleHook();
53
+
54
+ await waitFor(() =>
55
+ expect(result.current).toBe("My Conversation | OpenHands"),
56
+ );
57
+ });
58
+
59
+ it("returns the app name while conversation data is loading", async () => {
60
+ mockUseParams.mockReturnValue({ conversationId: "123" });
61
+ // @ts-expect-error - only returning partial config for test
62
+ mockUseUserConversation.mockReturnValue({ data: undefined });
63
+
64
+ const { result } = renderAppTitleHook();
65
+
66
+ await waitFor(() => expect(result.current).toBe("OpenHands"));
67
+ });
68
+
69
+ it.each([
70
+ [ExecutionStatus.RUNNING, "🟢"],
71
+ [ExecutionStatus.FINISHED, "✅"],
72
+ [ExecutionStatus.IDLE, "✅"],
73
+ [ExecutionStatus.WAITING_FOR_CONFIRMATION, "✅"],
74
+ [ExecutionStatus.PAUSED, "⚪"],
75
+ [ExecutionStatus.ERROR, "🔴"],
76
+ [ExecutionStatus.STUCK, "🔴"],
77
+ ])(
78
+ "prefixes the title with %s emoji for execution status %s",
79
+ async (status, emoji) => {
80
+ mockUseParams.mockReturnValue({ conversationId: "123" });
81
+ mockUseUserConversation.mockReturnValue({
82
+ // @ts-expect-error - only returning partial config for test
83
+ data: { title: "My Conversation" },
84
+ });
85
+ useConversationStateStore.getState().setExecutionStatus("123", status);
86
+
87
+ const { result } = renderAppTitleHook();
88
+
89
+ await waitFor(() =>
90
+ expect(result.current).toBe(`${emoji} My Conversation | OpenHands`),
91
+ );
92
+ },
93
+ );
94
+
95
+ it("falls back to the conversation's execution_status when the live store is empty", async () => {
96
+ mockUseParams.mockReturnValue({ conversationId: "123" });
97
+ mockUseUserConversation.mockReturnValue({
98
+ // @ts-expect-error - only returning partial config for test
99
+ data: {
100
+ title: "My Conversation",
101
+ execution_status: ExecutionStatus.RUNNING,
102
+ },
103
+ });
104
+
105
+ const { result } = renderAppTitleHook();
106
+
107
+ await waitFor(() =>
108
+ expect(result.current).toBe("🟢 My Conversation | OpenHands"),
109
+ );
110
+ });
111
+
112
+ it("does not add an emoji when in a conversation but execution status is unknown", async () => {
113
+ mockUseParams.mockReturnValue({ conversationId: "123" });
114
+ mockUseUserConversation.mockReturnValue({
115
+ // @ts-expect-error - only returning partial config for test
116
+ data: { title: "My Conversation" },
117
+ });
118
+
119
+ const { result } = renderAppTitleHook();
120
+
121
+ await waitFor(() =>
122
+ expect(result.current).toBe("My Conversation | OpenHands"),
123
+ );
124
+ });
125
+ });
src/hooks/use-app-title.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useParams } from "react-router";
2
+ import { useUserConversation } from "#/hooks/query/use-user-conversation";
3
+ import { useConversationStateStore } from "#/stores/conversation-state-store";
4
+ import { getAgentStateEmoji } from "#/utils/agent-state-emoji";
5
+
6
+ const APP_TITLE = "OpenHands";
7
+
8
+ export const useAppTitle = () => {
9
+ const { conversationId } = useParams<{ conversationId: string }>();
10
+ const { data: conversation } = useUserConversation(conversationId ?? null);
11
+ const liveExecutionStatus = useConversationStateStore((state) =>
12
+ conversationId
13
+ ? (state.executionStatusByConversation[conversationId] ?? null)
14
+ : null,
15
+ );
16
+
17
+ const conversationTitle = conversation?.title;
18
+ const baseTitle =
19
+ conversationId && conversationTitle
20
+ ? `${conversationTitle} | ${APP_TITLE}`
21
+ : APP_TITLE;
22
+
23
+ if (!conversationId) {
24
+ return baseTitle;
25
+ }
26
+
27
+ const executionStatus =
28
+ liveExecutionStatus ?? conversation?.execution_status ?? null;
29
+ const emoji = getAgentStateEmoji(executionStatus);
30
+
31
+ return emoji ? `${emoji} ${baseTitle}` : baseTitle;
32
+ };
src/hooks/use-auto-resize.ts ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, RefObject, useRef } from "react";
2
+ import { IMessageToSend } from "#/stores/conversation-store";
3
+ import { EPS } from "#/utils/constants";
4
+ import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils";
5
+ import { focusContentEditableAtEnd } from "#/components/features/chat/utils/chat-input.utils";
6
+ import { useDragResize } from "./use-drag-resize";
7
+
8
+ // Constants
9
+ const DEFAULT_MIN_HEIGHT = 20;
10
+ const DEFAULT_MAX_HEIGHT = 120;
11
+ const HEIGHT_INCREMENT = 20;
12
+ const MANUAL_OVERSIZE_THRESHOLD = 50;
13
+
14
+ // Manual height tracking utilities
15
+ const useManualHeight = () => {
16
+ const hasUserResizedRef = useRef(false);
17
+ const manualHeightRef = useRef<number | null>(null);
18
+ return { hasUserResizedRef, manualHeightRef };
19
+ };
20
+
21
+ interface UseAutoResizeOptions {
22
+ minHeight?: number;
23
+ maxHeight?: number;
24
+ enableManualResize?: boolean;
25
+ onGripDragStart?: () => void;
26
+ onGripDragEnd?: () => void;
27
+ onHeightChange?: (height: number) => void; // New callback for height changes
28
+ value?: IMessageToSend;
29
+ /** Called once `value` has been applied to the element (one-shot consume). */
30
+ onValueApplied?: () => void;
31
+ }
32
+
33
+ interface UseAutoResizeReturn {
34
+ smartResize: () => void;
35
+ handleGripMouseDown: (e: React.MouseEvent) => void;
36
+ handleGripTouchStart: (e: React.TouchEvent) => void;
37
+ increaseHeightForEmptyContent: () => void;
38
+ resetManualResize: () => void;
39
+ }
40
+
41
+ // Height management utilities
42
+ interface HeightConstraints {
43
+ minHeight: number;
44
+ maxHeight: number;
45
+ }
46
+
47
+ interface HeightMeasurements {
48
+ currentHeight: number;
49
+ currentStyleHeight: number;
50
+ contentHeight: number;
51
+ }
52
+
53
+ interface ResizeStrategy {
54
+ finalHeight: number;
55
+ overflowY: "hidden" | "auto";
56
+ }
57
+
58
+ const applyHeightToElement = (
59
+ element: HTMLElement,
60
+ height: number,
61
+ constraints: HeightConstraints,
62
+ ): number => {
63
+ const { minHeight, maxHeight } = constraints;
64
+ const finalHeight = Math.max(minHeight, Math.min(height, maxHeight));
65
+
66
+ element.style.setProperty("height", `${finalHeight}px`);
67
+ element.style.setProperty(
68
+ "overflow-y",
69
+ finalHeight >= maxHeight ? "auto" : "hidden",
70
+ );
71
+
72
+ return finalHeight;
73
+ };
74
+
75
+ const isManuallyOversized = (
76
+ currentHeight: number,
77
+ contentHeight: number,
78
+ threshold = MANUAL_OVERSIZE_THRESHOLD,
79
+ ): boolean => currentHeight > contentHeight + threshold;
80
+
81
+ const measureElementHeights = (
82
+ element: HTMLElement,
83
+ minHeight: number,
84
+ ): HeightMeasurements => {
85
+ // Use the previous explicit style height as the "current" for restore, not offsetHeight
86
+ const currentStyleHeight = getStyleHeightPx(element, minHeight);
87
+ const currentHeight = currentStyleHeight;
88
+
89
+ // Temporarily reset to measure content
90
+ element.style.setProperty("height", "auto");
91
+ const contentHeight = element.scrollHeight;
92
+
93
+ // Restore height
94
+ setStyleHeightPx(element, currentStyleHeight);
95
+
96
+ return {
97
+ currentHeight,
98
+ currentStyleHeight,
99
+ contentHeight,
100
+ };
101
+ };
102
+
103
+ const applyResizeStrategy = (
104
+ element: HTMLElement,
105
+ strategy: ResizeStrategy,
106
+ ): void => {
107
+ const { finalHeight, overflowY } = strategy;
108
+ const elementRef = element;
109
+ elementRef.style.height = `${finalHeight}px`;
110
+ elementRef.style.overflowY = overflowY;
111
+ };
112
+
113
+ const executeHeightCallback = (
114
+ height: number,
115
+ onHeightChange?: (height: number) => void,
116
+ ): void => {
117
+ if (onHeightChange) {
118
+ onHeightChange(height);
119
+ }
120
+ };
121
+
122
+ // DOM manipulation utilities
123
+ export const useAutoResize = (
124
+ elementRef: RefObject<HTMLElement | null>,
125
+ options: UseAutoResizeOptions = {},
126
+ ): UseAutoResizeReturn => {
127
+ const pendingSmartRef = useRef<number | null>(null);
128
+
129
+ const {
130
+ minHeight = DEFAULT_MIN_HEIGHT,
131
+ maxHeight = DEFAULT_MAX_HEIGHT,
132
+ enableManualResize = false,
133
+ value,
134
+ onValueApplied,
135
+ onGripDragStart,
136
+ onGripDragEnd,
137
+ onHeightChange,
138
+ } = options;
139
+
140
+ const constraints: HeightConstraints = { minHeight, maxHeight };
141
+ const { hasUserResizedRef, manualHeightRef } = useManualHeight();
142
+
143
+ const resetManualResize = () => {
144
+ hasUserResizedRef.current = false;
145
+ manualHeightRef.current = null;
146
+ };
147
+
148
+ // Wrap onHeightChange to track manual height during drag
149
+ const handleExternalHeightChange = useCallback(
150
+ (elementHeight: number) => {
151
+ onHeightChange?.(elementHeight);
152
+ if (hasUserResizedRef.current) {
153
+ manualHeightRef.current = elementHeight;
154
+ }
155
+ },
156
+ [onHeightChange],
157
+ );
158
+
159
+ // Handle drag start - set manual mode flag
160
+ const handleDragStart = useCallback(() => {
161
+ hasUserResizedRef.current = true;
162
+ onGripDragStart?.();
163
+ }, [onGripDragStart]);
164
+
165
+ // Handle drag end - clear manual mode if at minimum height
166
+ const handleDragEnd = useCallback(() => {
167
+ const textareaElement = elementRef.current;
168
+ if (textareaElement) {
169
+ const currentHeight = getStyleHeightPx(textareaElement, minHeight);
170
+ if (Math.abs(currentHeight - minHeight) <= EPS) {
171
+ hasUserResizedRef.current = false;
172
+ manualHeightRef.current = null;
173
+ }
174
+ }
175
+ onGripDragEnd?.();
176
+ }, [minHeight, onGripDragEnd]);
177
+
178
+ // Use the drag resize hook for manual resizing functionality
179
+ const { handleGripMouseDown, handleGripTouchStart } = useDragResize({
180
+ elementRef,
181
+ minHeight,
182
+ maxHeight,
183
+ onGripDragStart: enableManualResize ? handleDragStart : undefined,
184
+ onGripDragEnd: enableManualResize ? handleDragEnd : undefined,
185
+ onHeightChange: handleExternalHeightChange,
186
+ });
187
+
188
+ // Handle content that fits within current height
189
+ const handleContentFitsInCurrentHeight = useCallback(
190
+ (
191
+ element: HTMLElement,
192
+ currentHeight: number,
193
+ contentHeight: number,
194
+ ): void => {
195
+ // If user manually resized and we're above min height, preserve their chosen height
196
+ if (hasUserResizedRef.current && currentHeight > minHeight) {
197
+ applyResizeStrategy(element, {
198
+ finalHeight: currentHeight,
199
+ overflowY: "hidden",
200
+ });
201
+ executeHeightCallback(currentHeight, onHeightChange);
202
+ return;
203
+ }
204
+
205
+ // Otherwise allow shrinking towards content (respect minHeight)
206
+ const finalHeight = Math.max(contentHeight, minHeight);
207
+ applyResizeStrategy(element, {
208
+ finalHeight,
209
+ overflowY: "hidden",
210
+ });
211
+ executeHeightCallback(finalHeight, onHeightChange);
212
+ },
213
+ [minHeight, onHeightChange],
214
+ );
215
+
216
+ // Handle content that exceeds current height but within max height
217
+ const handleContentExceedsCurrentHeight = useCallback(
218
+ (
219
+ element: HTMLElement,
220
+ currentHeight: number,
221
+ contentHeight: number,
222
+ ): void => {
223
+ // Grow unless the element is manually oversized beyond content significantly
224
+ if (!isManuallyOversized(currentHeight, contentHeight)) {
225
+ const finalHeight = Math.max(contentHeight, minHeight);
226
+ applyResizeStrategy(element, {
227
+ finalHeight,
228
+ overflowY: "hidden",
229
+ });
230
+ executeHeightCallback(finalHeight, onHeightChange);
231
+ return;
232
+ }
233
+
234
+ // Keep manual height and allow scrolling as needed
235
+ applyResizeStrategy(element, {
236
+ finalHeight: currentHeight,
237
+ overflowY: "auto",
238
+ });
239
+ executeHeightCallback(currentHeight, onHeightChange);
240
+ },
241
+ [minHeight, onHeightChange],
242
+ );
243
+
244
+ // Handle content that exceeds max height
245
+ const handleContentExceedsMaxHeight = useCallback(
246
+ (element: HTMLElement) => {
247
+ applyResizeStrategy(element, {
248
+ finalHeight: maxHeight,
249
+ overflowY: "auto",
250
+ });
251
+ executeHeightCallback(maxHeight, onHeightChange);
252
+ },
253
+ [maxHeight, onHeightChange],
254
+ );
255
+
256
+ // Debounced smartResize body
257
+ const smartResizeBody = useCallback(() => {
258
+ const element = elementRef.current;
259
+ if (!element) return;
260
+
261
+ const textIsEmpty = (element.textContent ?? "").trim().length === 0;
262
+
263
+ // If empty content and we have a manual height above min, preserve it
264
+ if (
265
+ textIsEmpty &&
266
+ hasUserResizedRef.current &&
267
+ manualHeightRef.current &&
268
+ manualHeightRef.current > minHeight + EPS
269
+ ) {
270
+ setStyleHeightPx(element, manualHeightRef.current);
271
+ element.style.overflowY = "hidden";
272
+ executeHeightCallback(manualHeightRef.current, onHeightChange);
273
+ return;
274
+ }
275
+
276
+ // Measure element heights
277
+ const measurements = measureElementHeights(element, minHeight);
278
+ const { currentHeight, contentHeight } = measurements;
279
+
280
+ // If content fits within current height
281
+ if (contentHeight <= currentHeight) {
282
+ handleContentFitsInCurrentHeight(element, currentHeight, contentHeight);
283
+ return;
284
+ }
285
+
286
+ // If content exceeds current height but within max
287
+ if (contentHeight <= maxHeight) {
288
+ handleContentExceedsCurrentHeight(element, currentHeight, contentHeight);
289
+ return;
290
+ }
291
+
292
+ // Content exceeds max height
293
+ handleContentExceedsMaxHeight(element);
294
+ }, [
295
+ elementRef,
296
+ minHeight,
297
+ maxHeight,
298
+ onHeightChange,
299
+ handleContentFitsInCurrentHeight,
300
+ handleContentExceedsCurrentHeight,
301
+ handleContentExceedsMaxHeight,
302
+ ]);
303
+
304
+ // rAF-debounced smartResize wrapper to collapse bursts
305
+ const smartResize = useCallback(() => {
306
+ if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current);
307
+ pendingSmartRef.current = requestAnimationFrame(() => {
308
+ pendingSmartRef.current = null;
309
+ smartResizeBody();
310
+ });
311
+ }, [smartResizeBody]);
312
+
313
+ // Function to increase height when content is empty
314
+ const increaseHeightForEmptyContent = () => {
315
+ const element = elementRef.current;
316
+ if (!element) return;
317
+
318
+ const currentHeight = getStyleHeightPx(element, minHeight);
319
+ const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight);
320
+
321
+ if (newHeight > currentHeight) {
322
+ const finalHeight = applyHeightToElement(element, newHeight, constraints);
323
+
324
+ // Execute height change callback
325
+ executeHeightCallback(finalHeight, onHeightChange);
326
+
327
+ // Set manual mode for Shift+Enter height increases
328
+ hasUserResizedRef.current = true;
329
+ manualHeightRef.current = finalHeight;
330
+ }
331
+ };
332
+
333
+ // Update content and resize when value prop changes
334
+ useEffect(() => {
335
+ if (value === undefined) {
336
+ return undefined;
337
+ }
338
+
339
+ let rafId = 0;
340
+
341
+ const applyValue = () => {
342
+ const element = elementRef.current;
343
+ if (!element) {
344
+ return false;
345
+ }
346
+
347
+ element.textContent = value.text;
348
+ smartResize();
349
+ focusContentEditableAtEnd(element);
350
+ onValueApplied?.(); // one-shot: let the caller clear the value
351
+ return true;
352
+ };
353
+
354
+ if (!applyValue()) {
355
+ rafId = requestAnimationFrame(() => {
356
+ applyValue();
357
+ });
358
+ }
359
+
360
+ return () => {
361
+ if (rafId) {
362
+ cancelAnimationFrame(rafId);
363
+ }
364
+ };
365
+ }, [value, smartResize, onValueApplied]);
366
+
367
+ // Initialize auto-resize on mount
368
+ useEffect(() => {
369
+ smartResize();
370
+ }, [smartResize]);
371
+
372
+ return {
373
+ smartResize,
374
+ handleGripMouseDown,
375
+ handleGripTouchStart,
376
+ increaseHeightForEmptyContent,
377
+ resetManualResize,
378
+ };
379
+ };
src/hooks/use-automation-permissions.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
3
+ import { useActiveBackend } from "#/contexts/active-backend-context";
4
+ import { useCloudCurrentUserId } from "./query/use-cloud-current-user-id";
5
+ import type { Automation } from "#/types/automation";
6
+
7
+ /**
8
+ * Server-defined permission strings (mirror the automation service's
9
+ * `view_automations` / `manage_automations` and the enterprise
10
+ * `authorization.Permission` enum). The `/me` endpoint returns whichever
11
+ * subset the caller's role grants.
12
+ */
13
+ export const VIEW_AUTOMATIONS = "view_automations";
14
+ export const MANAGE_AUTOMATIONS = "manage_automations";
15
+
16
+ export interface AutomationPermissionsResult {
17
+ /** Read-only access (list, get, list runs, capabilities, git-sync status). */
18
+ canView: boolean;
19
+ /** Full write access (create, update, delete, dispatch, git-sync config). */
20
+ canManage: boolean;
21
+ /**
22
+ * `true` when permissions are still being resolved on cloud. Local always
23
+ * resolves synchronously. Mutating controls should stay hidden while this is
24
+ * `true` so a member never sees a flash of write controls before the
25
+ * server-defined permission set arrives.
26
+ */
27
+ isLoading: boolean;
28
+ }
29
+
30
+ /**
31
+ * Resolve the caller's automation-level permissions on the active backend.
32
+ *
33
+ * - Local agent-server (OSS): always `{ canView: true, canManage: true }`.
34
+ * - Cloud: reads the server-defined `permissions` array from
35
+ * `GET /api/organizations/{orgId}/me` (same query `useCloudCurrentUserId`
36
+ * and `useCanManageOrgProfiles` use, so no extra request is issued) and
37
+ * falls back to a role check (`owner`/`admin` ⇒ both, `member` ⇒ view
38
+ * only) when an older app-server doesn't return `permissions`.
39
+ */
40
+ export function useAutomationPermissions(): AutomationPermissionsResult {
41
+ const { backend, orgId } = useActiveBackend();
42
+ const isCloud = backend.kind === "cloud";
43
+
44
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps
45
+ const { data, isLoading } = useQuery({
46
+ queryKey: [
47
+ "cloud-current-user",
48
+ backend.id,
49
+ orgId,
50
+ backend.connectionRevision ?? 0,
51
+ ],
52
+ queryFn: () => getCloudOrganizationMe(orgId!, backend),
53
+ enabled: isCloud && !!orgId,
54
+ staleTime: 1000 * 60 * 5,
55
+ retry: false,
56
+ meta: { disableToast: true },
57
+ });
58
+
59
+ if (!isCloud) {
60
+ return { canView: true, canManage: true, isLoading: false };
61
+ }
62
+
63
+ if (isLoading || !data) {
64
+ return { canView: false, canManage: false, isLoading };
65
+ }
66
+
67
+ if (data.permissions) {
68
+ const canView = data.permissions.includes(VIEW_AUTOMATIONS);
69
+ const canManage = data.permissions.includes(MANAGE_AUTOMATIONS);
70
+ return { canView, canManage, isLoading: false };
71
+ }
72
+
73
+ // Fall back to role check for older app-servers without `permissions`.
74
+ const isOwnerOrAdmin = data.role === "owner" || data.role === "admin";
75
+ return {
76
+ canView: isOwnerOrAdmin || data.role === "member",
77
+ canManage: isOwnerOrAdmin,
78
+ isLoading: false,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Resolve whether the current user is the creator of the given automation.
84
+ *
85
+ * - Local: always `true` (the local user owns every automation; and since
86
+ * local always has `manage_automations`, the distinction is moot).
87
+ * - Cloud: compares `automation.user_id` with the caller's `user_id` from
88
+ * `GET /api/organizations/{orgId}/me`. Returns `false` while the id is
89
+ * still loading so the creator escape hatch never flashes open prematurely.
90
+ */
91
+ export function useIsAutomationOwner(automation: Automation): boolean {
92
+ const { backend } = useActiveBackend();
93
+ // Always call the hook so rules-of-hooks is satisfied; it's a no-op for
94
+ // local backends since the early return below short-circuits before use.
95
+ const userIds = useCloudCurrentUserId();
96
+
97
+ if (backend.kind !== "cloud") return true;
98
+
99
+ const entry = userIds[backend.id];
100
+ if (!entry || entry.isLoading || !entry.userId) return false;
101
+ return automation.user_id === entry.userId;
102
+ }
src/hooks/use-await-context-compaction.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import useMetricsStore from "#/stores/metrics-store";
3
+ import { useEventStore } from "#/stores/use-event-store";
4
+ import { isCondensationEvent } from "#/types/agent-server/type-guards";
5
+
6
+ /** How long to wait after a Condensation event for a lower per_turn_token. */
7
+ const METRICS_SETTLE_MS = 2_500;
8
+ /** Give up waiting for condensation / metrics and surface whatever we have. */
9
+ const DEFAULT_TIMEOUT_MS = 90_000;
10
+
11
+ export type ContextCompactionOutcome = "compacted" | "no_change" | "timeout";
12
+
13
+ export interface ContextCompactionResult {
14
+ beforeToken: number;
15
+ afterToken: number;
16
+ savedToken: number;
17
+ /**
18
+ * "compacted" — a Condensation event landed and per_turn_token dropped.
19
+ * "no_change" — a Condensation event landed but no token drop was measured.
20
+ * "timeout" — no Condensation event arrived in time; compaction did not
21
+ * happen (or was never processed), so this is a failure, not a no-op.
22
+ */
23
+ outcome: ContextCompactionOutcome;
24
+ }
25
+
26
+ export function buildContextCompactionResult(
27
+ beforeToken: number,
28
+ afterToken: number,
29
+ outcome: ContextCompactionOutcome,
30
+ ): ContextCompactionResult {
31
+ return {
32
+ beforeToken,
33
+ afterToken,
34
+ savedToken: Math.max(0, beforeToken - afterToken),
35
+ outcome,
36
+ };
37
+ }
38
+
39
+ interface UseAwaitContextCompactionOptions {
40
+ /**
41
+ * Snapshot of `per_turn_token` taken when compaction was requested.
42
+ * Pass `null` when not awaiting a result.
43
+ */
44
+ beforeToken: number | null;
45
+ /**
46
+ * Event ids that predate the compaction *request*. The condense POST can
47
+ * return only after the server already emitted its Condensation event, so
48
+ * snapshotting "known" ids when this effect starts would miss fast
49
+ * condensations; the baseline must be captured before the request fires.
50
+ * When omitted, falls back to the ids present when the effect starts.
51
+ */
52
+ baselineEventIds?: Set<string | number> | null;
53
+ onComplete: (result: ContextCompactionResult) => void;
54
+ timeoutMs?: number;
55
+ }
56
+
57
+ /**
58
+ * Waits for a post-request Condensation event (and ideally a lower
59
+ * `per_turn_token` in the live metrics store), then reports how many tokens
60
+ * were freed. The HTTP `/condense` ack only means work *started*.
61
+ */
62
+ export function useAwaitContextCompaction({
63
+ beforeToken,
64
+ baselineEventIds,
65
+ onComplete,
66
+ timeoutMs = DEFAULT_TIMEOUT_MS,
67
+ }: UseAwaitContextCompactionOptions) {
68
+ const onCompleteRef = useRef(onComplete);
69
+ onCompleteRef.current = onComplete;
70
+
71
+ useEffect(() => {
72
+ if (beforeToken === null) {
73
+ return undefined;
74
+ }
75
+
76
+ let completed = false;
77
+ let sawCondensation = false;
78
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
79
+ const knownEventIds = baselineEventIds
80
+ ? new Set(baselineEventIds)
81
+ : new Set(useEventStore.getState().eventIds);
82
+
83
+ const readAfterToken = () =>
84
+ useMetricsStore.getState().usage?.per_turn_token ?? beforeToken;
85
+
86
+ const finish = (afterToken: number, outcome: ContextCompactionOutcome) => {
87
+ if (completed) {
88
+ return;
89
+ }
90
+ completed = true;
91
+ onCompleteRef.current(
92
+ buildContextCompactionResult(beforeToken, afterToken, outcome),
93
+ );
94
+ };
95
+
96
+ const finishFromMetricsIfReady = () => {
97
+ const afterToken = readAfterToken();
98
+ if (sawCondensation && afterToken < beforeToken) {
99
+ finish(afterToken, "compacted");
100
+ return true;
101
+ }
102
+ return false;
103
+ };
104
+
105
+ const scheduleSettleFinish = () => {
106
+ clearTimeout(settleTimer);
107
+ settleTimer = setTimeout(() => {
108
+ finish(readAfterToken(), "no_change");
109
+ }, METRICS_SETTLE_MS);
110
+ };
111
+
112
+ const scanForNewCondensation = () => {
113
+ for (const event of useEventStore.getState().events) {
114
+ const eventId = "id" in event ? event.id : undefined;
115
+ if (eventId !== undefined) {
116
+ if (knownEventIds.has(eventId)) {
117
+ continue;
118
+ }
119
+ knownEventIds.add(eventId);
120
+ }
121
+ if (!isCondensationEvent(event)) {
122
+ continue;
123
+ }
124
+ sawCondensation = true;
125
+ if (!finishFromMetricsIfReady()) {
126
+ scheduleSettleFinish();
127
+ }
128
+ return;
129
+ }
130
+ };
131
+
132
+ scanForNewCondensation();
133
+
134
+ const unsubscribeEvents = useEventStore.subscribe(() => {
135
+ if (completed) {
136
+ return;
137
+ }
138
+ scanForNewCondensation();
139
+ });
140
+
141
+ const unsubscribeMetrics = useMetricsStore.subscribe(() => {
142
+ if (completed) {
143
+ return;
144
+ }
145
+ if (finishFromMetricsIfReady()) {
146
+ clearTimeout(settleTimer);
147
+ }
148
+ });
149
+
150
+ const timeoutTimer = setTimeout(() => {
151
+ // No Condensation event in time means the compaction never landed —
152
+ // a failure, not a "nothing to compact" no-op.
153
+ finish(readAfterToken(), sawCondensation ? "no_change" : "timeout");
154
+ }, timeoutMs);
155
+
156
+ return () => {
157
+ completed = true;
158
+ unsubscribeEvents();
159
+ unsubscribeMetrics();
160
+ clearTimeout(settleTimer);
161
+ clearTimeout(timeoutTimer);
162
+ };
163
+ }, [beforeToken, baselineEventIds, timeoutMs]);
164
+ }
src/hooks/use-backend-scoped-path.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { isNoBackend } from "#/api/backend-registry/active-store";
3
+ import { withBackendSelectionParams } from "#/api/backend-registry/url-selection";
4
+ import { useActiveBackend } from "#/contexts/active-backend-context";
5
+
6
+ /**
7
+ * Build in-app paths that carry the active backend identity.
8
+ *
9
+ * Use it for links a user can plausibly open in a *new* browsing context
10
+ * (cmd/ctrl-click, middle click, "Open link in new tab"). Such a tab does not
11
+ * reliably inherit the opener's `sessionStorage`, so without the pinned
12
+ * identity it can boot on whichever backend `localStorage` last recorded and
13
+ * fail to resolve the linked conversation.
14
+ */
15
+ export function useBackendScopedPath(): (path: string) => string {
16
+ const active = useActiveBackend();
17
+
18
+ return React.useCallback(
19
+ (path: string) => {
20
+ // The sentinel isn't a real backend and must never be pinned onto a
21
+ // link — a new tab would only ignore it and fall back to storage.
22
+ if (isNoBackend(active.backend)) return path;
23
+ return withBackendSelectionParams(path, active);
24
+ },
25
+ [active],
26
+ );
27
+ }
src/hooks/use-bash-command-runner.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type {
3
+ BashCommand,
4
+ BashError,
5
+ BashEvent,
6
+ BashOutput,
7
+ } from "@openhands/typescript-client";
8
+ import type { CommandResult } from "#/api/runtime-service/agent-server-runtime-service";
9
+ import { sendWebSocketAuth } from "#/utils/websocket-auth";
10
+ import { startHandshakeWatchdog } from "#/utils/websocket-handshake";
11
+ import { buildBashWebSocketUrl } from "#/utils/websocket-url";
12
+
13
+ interface WaitingCommand {
14
+ command: string;
15
+ cwd: string;
16
+ timeout: number;
17
+ resolve: (result: CommandResult) => void;
18
+ reject: (error: Error) => void;
19
+ }
20
+
21
+ interface PendingCommand {
22
+ resolve: (result: CommandResult) => void;
23
+ reject: (error: Error) => void;
24
+ }
25
+
26
+ interface ActiveCommand extends PendingCommand {
27
+ stdout: string[];
28
+ stderr: string[];
29
+ }
30
+
31
+ export type BashCommandRunner = (
32
+ command: string,
33
+ cwd: string,
34
+ timeout: number,
35
+ ) => Promise<CommandResult>;
36
+
37
+ function isBashCommand(event: BashEvent): event is BashCommand {
38
+ return event.kind === "BashCommand";
39
+ }
40
+
41
+ function isBashOutput(event: BashEvent): event is BashOutput {
42
+ return event.kind === "BashOutput";
43
+ }
44
+
45
+ function isBashError(event: BashEvent): event is BashError {
46
+ return event.kind === "BashError";
47
+ }
48
+
49
+ /**
50
+ * Maintains a persistent WebSocket connection to the agent-server's
51
+ * `/sockets/bash-events` endpoint and exposes a `runCommand` function that
52
+ * executes a bash command and returns a Promise that resolves when the
53
+ * final `BashOutput` (non-null `exit_code`) arrives.
54
+ *
55
+ * Commands are correlated using a FIFO queue: each `BashCommand` echo
56
+ * received from the server is paired with the oldest outstanding request in
57
+ * the queue, and subsequent `BashOutput` events are matched by `command_id`.
58
+ *
59
+ * Commands are buffered until the socket's open handler sends authentication.
60
+ */
61
+ export function useBashCommandRunner(
62
+ conversationUrl: string | null | undefined,
63
+ sessionApiKey: string | null | undefined,
64
+ enabled: boolean,
65
+ ): BashCommandRunner {
66
+ const wsRef = useRef<WebSocket | null>(null);
67
+ const readyWsRef = useRef<WebSocket | null>(null);
68
+ const waitingQueueRef = useRef<WaitingCommand[]>([]);
69
+ // Commands whose request was sent; waiting for the BashCommand echo to get command_id
70
+ const pendingQueueRef = useRef<PendingCommand[]>([]);
71
+ // Commands whose command_id is known; waiting for BashOutput with non-null exit_code
72
+ const activeCommandsRef = useRef<Map<string, ActiveCommand>>(new Map());
73
+
74
+ useEffect(() => {
75
+ if (!enabled) return;
76
+
77
+ const wsUrl = buildBashWebSocketUrl(conversationUrl);
78
+ const ws = new WebSocket(wsUrl);
79
+ wsRef.current = ws;
80
+ readyWsRef.current = null;
81
+
82
+ // Abort a handshake stuck in CONNECTING — a hung bash-events handshake
83
+ // would also block the conversation's events socket (the one the chat
84
+ // needs) until it settles.
85
+ const cancelHandshakeWatchdog = startHandshakeWatchdog(ws);
86
+
87
+ ws.onopen = () => {
88
+ cancelHandshakeWatchdog();
89
+ sendWebSocketAuth(ws, sessionApiKey);
90
+ readyWsRef.current = ws;
91
+ for (const {
92
+ command,
93
+ cwd,
94
+ timeout,
95
+ resolve,
96
+ reject,
97
+ } of waitingQueueRef.current) {
98
+ pendingQueueRef.current.push({ resolve, reject });
99
+ ws.send(JSON.stringify({ command, cwd, timeout }));
100
+ }
101
+ waitingQueueRef.current = [];
102
+ };
103
+
104
+ ws.onmessage = (event: MessageEvent) => {
105
+ let data: BashEvent;
106
+ try {
107
+ data = JSON.parse(event.data as string) as BashEvent;
108
+ } catch {
109
+ return; // ignore malformed frames
110
+ }
111
+
112
+ if (isBashCommand(data)) {
113
+ // Associate the next pending request with the server-assigned command_id
114
+ const pending = pendingQueueRef.current.shift();
115
+ if (pending) {
116
+ activeCommandsRef.current.set(data.id, {
117
+ ...pending,
118
+ stdout: [],
119
+ stderr: [],
120
+ });
121
+ }
122
+ } else if (isBashOutput(data) && data.command_id) {
123
+ const active = activeCommandsRef.current.get(data.command_id);
124
+ if (active) {
125
+ if (data.stdout) active.stdout.push(data.stdout);
126
+ if (data.stderr) active.stderr.push(data.stderr);
127
+ if (data.exit_code != null) {
128
+ activeCommandsRef.current.delete(data.command_id);
129
+ active.resolve({
130
+ exit_code: data.exit_code,
131
+ stdout: active.stdout.join(""),
132
+ stderr: active.stderr.join(""),
133
+ });
134
+ }
135
+ }
136
+ } else if (isBashError(data)) {
137
+ rejectAll(`Bash error: ${data.code}: ${data.detail}`);
138
+ }
139
+ };
140
+
141
+ function rejectAll(reason: string): void {
142
+ const err = new Error(reason);
143
+ for (const { reject: rej } of waitingQueueRef.current) rej(err);
144
+ waitingQueueRef.current = [];
145
+ for (const p of pendingQueueRef.current) p.reject(err);
146
+ pendingQueueRef.current = [];
147
+ for (const a of activeCommandsRef.current.values()) a.reject(err);
148
+ activeCommandsRef.current.clear();
149
+ }
150
+
151
+ ws.onclose = () => {
152
+ cancelHandshakeWatchdog();
153
+ wsRef.current = null;
154
+ readyWsRef.current = null;
155
+ rejectAll("Bash WebSocket closed");
156
+ };
157
+
158
+ ws.onerror = () => {
159
+ wsRef.current = null;
160
+ readyWsRef.current = null;
161
+ rejectAll("Bash WebSocket error");
162
+ };
163
+
164
+ return () => {
165
+ // The close handler is nulled below, so cancel the watchdog here.
166
+ cancelHandshakeWatchdog();
167
+ // Prevent the close/error handlers from double-rejecting after unmount
168
+ ws.onclose = null;
169
+ ws.onerror = null;
170
+ ws.close();
171
+ wsRef.current = null;
172
+ readyWsRef.current = null;
173
+ rejectAll("Bash WebSocket unmounted");
174
+ };
175
+ }, [enabled, conversationUrl, sessionApiKey]);
176
+
177
+ const runCommand: BashCommandRunner = useCallback(
178
+ (command: string, cwd: string, timeout: number) =>
179
+ new Promise<CommandResult>((resolve, reject) => {
180
+ const ws = wsRef.current;
181
+ if (
182
+ !ws ||
183
+ ws.readyState === WebSocket.CLOSED ||
184
+ ws.readyState === WebSocket.CLOSING
185
+ ) {
186
+ reject(new Error("Bash WebSocket not available"));
187
+ return;
188
+ }
189
+ if (ws.readyState !== WebSocket.OPEN || readyWsRef.current !== ws) {
190
+ waitingQueueRef.current.push({
191
+ command,
192
+ cwd,
193
+ timeout,
194
+ resolve,
195
+ reject,
196
+ });
197
+ } else {
198
+ pendingQueueRef.current.push({ resolve, reject });
199
+ ws.send(JSON.stringify({ command, cwd, timeout }));
200
+ }
201
+ }),
202
+ [],
203
+ );
204
+
205
+ return runCommand;
206
+ }