diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 810237026f0..380c0b6b072 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -409,6 +409,23 @@ "plan.0.step" ] }, + "spawn_task": { + "emoji": "✨", + "title": "Suggest Task", + "detailKeys": [ + "title", + "tldr", + "cwd" + ] + }, + "dismiss_task": { + "emoji": "🗑️", + "title": "Dismiss Task", + "detailKeys": [ + "task_id", + "reason" + ] + }, "skill_workshop": { "emoji": "🧰", "title": "Skill Workshop", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 182c1818503..21356b0b516 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -57,6 +57,12 @@ public enum SessionFileRelevance: String, Codable, Sendable { case mixed = "mixed" } +public enum TaskSuggestionResolution: String, Codable, Sendable { + case dismissed = "dismissed" + case accepted = "accepted" + case expired = "expired" +} + public struct ConnectParams: Codable, Sendable { public let minprotocol: Int public let maxprotocol: Int @@ -2602,6 +2608,7 @@ public struct SessionsCreateParams: Codable, Sendable { public let task: String? public let message: String? public let worktree: Bool? + public let cwd: String? public init( key: String?, @@ -2613,7 +2620,8 @@ public struct SessionsCreateParams: Codable, Sendable { emitcommandhooks: Bool?, task: String?, message: String?, - worktree: Bool?) + worktree: Bool?, + cwd: String?) { self.key = key self.agentid = agentid @@ -2625,6 +2633,7 @@ public struct SessionsCreateParams: Codable, Sendable { self.task = task self.message = message self.worktree = worktree + self.cwd = cwd } private enum CodingKeys: String, CodingKey { @@ -2638,6 +2647,7 @@ public struct SessionsCreateParams: Codable, Sendable { case task case message case worktree + case cwd } } @@ -3231,6 +3241,200 @@ public struct AuditListResult: Codable, Sendable { } } +public struct TaskSuggestion: Codable, Sendable { + public let id: String + public let title: String + public let prompt: String + public let tldr: String + public let cwd: String + public let sessionkey: String + public let agentid: String? + public let createdat: Int + + public init( + id: String, + title: String, + prompt: String, + tldr: String, + cwd: String, + sessionkey: String, + agentid: String? = nil, + createdat: Int) + { + self.id = id + self.title = title + self.prompt = prompt + self.tldr = tldr + self.cwd = cwd + self.sessionkey = sessionkey + self.agentid = agentid + self.createdat = createdat + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case prompt + case tldr + case cwd + case sessionkey = "sessionKey" + case agentid = "agentId" + case createdat = "createdAt" + } +} + +public struct TaskSuggestionsAcceptParams: Codable, Sendable { + public let taskid: String + + public init( + taskid: String) + { + self.taskid = taskid + } + + private enum CodingKeys: String, CodingKey { + case taskid = "taskId" + } +} + +public struct TaskSuggestionsAcceptResult: Codable, Sendable { + public let taskid: String + public let key: String + + public init( + taskid: String, + key: String) + { + self.taskid = taskid + self.key = key + } + + private enum CodingKeys: String, CodingKey { + case taskid = "taskId" + case key + } +} + +public struct TaskSuggestionsCreateParams: Codable, Sendable { + public let title: String + public let prompt: String + public let tldr: String + public let cwd: String + public let sessionkey: String + public let agentid: String? + + public init( + title: String, + prompt: String, + tldr: String, + cwd: String, + sessionkey: String, + agentid: String? = nil) + { + self.title = title + self.prompt = prompt + self.tldr = tldr + self.cwd = cwd + self.sessionkey = sessionkey + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case title + case prompt + case tldr + case cwd + case sessionkey = "sessionKey" + case agentid = "agentId" + } +} + +public struct TaskSuggestionsCreateResult: Codable, Sendable { + public let taskid: String + public let suggestion: TaskSuggestion + + public init( + taskid: String, + suggestion: TaskSuggestion) + { + self.taskid = taskid + self.suggestion = suggestion + } + + private enum CodingKeys: String, CodingKey { + case taskid = "taskId" + case suggestion + } +} + +public struct TaskSuggestionsDismissParams: Codable, Sendable { + public let taskid: String + public let reason: String? + + public init( + taskid: String, + reason: String?) + { + self.taskid = taskid + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case taskid = "taskId" + case reason + } +} + +public struct TaskSuggestionsDismissResult: Codable, Sendable { + public let taskid: String + public let dismissed: Bool + + public init( + taskid: String, + dismissed: Bool) + { + self.taskid = taskid + self.dismissed = dismissed + } + + private enum CodingKeys: String, CodingKey { + case taskid = "taskId" + case dismissed + } +} + +public struct TaskSuggestionsListParams: Codable, Sendable { + public let sessionkey: String? + public let agentid: String? + + public init( + sessionkey: String?, + agentid: String? = nil) + { + self.sessionkey = sessionkey + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + } +} + +public struct TaskSuggestionsListResult: Codable, Sendable { + public let suggestions: [TaskSuggestion] + + public init( + suggestions: [TaskSuggestion]) + { + self.suggestions = suggestions + } + + private enum CodingKeys: String, CodingKey { + case suggestions + } +} + public struct TaskSummary: Codable, Sendable { public let id: String public let kind: String? diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 7aedf25ace7..6aee5660ccd 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -47,8 +47,12 @@ A nonzero exit aborts creation and removes the new worktree and branch. This is Start an isolated chat from the active agent's git workspace with **New chat in worktree**: use the secondary New Chat action in the Control UI sidebar, the Chat actions menu on iOS, or the overflow action beside New Chat on Android. The action is available only for a git-backed agent where the client has that capability; clients that cannot preflight it surface the gateway error instead. +Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the chip leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. + The resulting managed worktree is owned by the session, and every agent run in that session uses its checkout. When the workspace is a repository subdirectory, the worktree is anchored at the repository root and the session runs from the matching subdirectory inside it. Session worktree creation uses the method's `operator.write` scope, but the `.openclaw/worktree-setup.sh` step runs only for `operator.admin` callers because it executes repository code; `.worktreeinclude` provisioning still applies to every caller. Deleting the session removes the worktree only when doing so is lossless. Dirty worktrees or branches with unpushed commits stay available; hourly cleanup snapshots session worktrees after 7 idle days, treating recent session activity as worktree activity. Removed worktrees remain restorable from their snapshots as described below. +`sessions.create` may include an absolute `cwd` together with `worktree: true` when a task targets a project other than the configured agent workspace. That explicit host path requires `operator.admin`; ordinary worktree chat creation remains `operator.write` and stays anchored to the configured workspace. + ## Snapshots, cleanup, and restore Removal first creates a synthetic commit containing tracked and non-ignored untracked files, and pins it at `refs/openclaw/snapshots/`. Gitignored files are excluded from the repository object database; files selected by `.worktreeinclude` are copied again during restore. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 5d00fad91e9..d3d7cb8188b 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -31,21 +31,23 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un ### Tool groups -| Group | Tools | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------- | -| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) | -| `group:fs` | `read`, `write`, `edit`, `apply_patch` | -| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status` | -| `group:memory` | `memory_search`, `memory_get` | -| `group:web` | `web_search`, `x_search`, `web_fetch` | -| `group:ui` | `browser`, `canvas` | -| `group:automation` | `heartbeat_respond`, `cron`, `gateway` | -| `group:messaging` | `message` | -| `group:nodes` | `nodes` | -| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` | -| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | -| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) | -| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` | +| Group | Tools | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) | +| `group:fs` | `read`, `write`, `edit`, `apply_patch` | +| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status`, `spawn_task`, `dismiss_task` | +| `group:memory` | `memory_search`, `memory_get` | +| `group:web` | `web_search`, `x_search`, `web_fetch` | +| `group:ui` | `browser`, `canvas` | +| `group:automation` | `heartbeat_respond`, `cron`, `gateway` | +| `group:messaging` | `message` | +| `group:nodes` | `nodes` | +| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` | +| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | +| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) | +| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` | + +`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; accepting it creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. Suggestions are process-local and disappear when the Gateway restarts. Both tools are in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically. ### MCP and plugin tools inside sandbox tool policy diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 25300ab7751..540b0df5160 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -130,6 +130,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T - Hovering or keyboard-focusing a public GitHub issue or pull request link shows its state, title, author, recent activity, comments, and change statistics. The connected Gateway fetches and caches public metadata without changing the link target, including when the UI uses a remote Gateway. The Gateway uses `GH_TOKEN` or `GITHUB_TOKEN` when available, after confirming the repository is public; otherwise it uses GitHub's anonymous API with a longer cache. - Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio`, forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model, and routes active-run voice steering through `talk.client.steer` or `talk.session.steer`. - Stream tool calls and live tool output cards in Chat (agent events). + - Start or dismiss ephemeral model-suggested follow-up tasks; accepted suggestions open a fresh managed-worktree session with the proposed prompt. - Activity tab with browser-local, redaction-first summaries of live tool activity from existing `session.tool` / tool event delivery. diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 3a835bbacbd..c9eda2ceaf9 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -447,6 +447,28 @@ import { SessionsSendParamsSchema, type SessionsUsageParams, SessionsUsageParamsSchema, + type TaskSuggestion, + type TaskSuggestionEvent, + TaskSuggestionEventSchema, + type TaskSuggestionResolution, + TaskSuggestionResolutionSchema, + TaskSuggestionSchema, + type TaskSuggestionsAcceptParams, + TaskSuggestionsAcceptParamsSchema, + type TaskSuggestionsAcceptResult, + TaskSuggestionsAcceptResultSchema, + type TaskSuggestionsCreateParams, + TaskSuggestionsCreateParamsSchema, + type TaskSuggestionsCreateResult, + TaskSuggestionsCreateResultSchema, + type TaskSuggestionsDismissParams, + TaskSuggestionsDismissParamsSchema, + type TaskSuggestionsDismissResult, + TaskSuggestionsDismissResultSchema, + type TaskSuggestionsListParams, + TaskSuggestionsListParamsSchema, + type TaskSuggestionsListResult, + TaskSuggestionsListResultSchema, type TaskSummary, TaskSummarySchema, type TasksCancelParams, @@ -832,6 +854,18 @@ export const validateSessionsCompactionRestoreParams = lazyCompile(SessionsUsageParamsSchema); +export const validateTaskSuggestionsListParams = lazyCompile( + TaskSuggestionsListParamsSchema, +); +export const validateTaskSuggestionsCreateParams = lazyCompile( + TaskSuggestionsCreateParamsSchema, +); +export const validateTaskSuggestionsAcceptParams = lazyCompile( + TaskSuggestionsAcceptParamsSchema, +); +export const validateTaskSuggestionsDismissParams = lazyCompile( + TaskSuggestionsDismissParamsSchema, +); export const validateTasksListParams = lazyCompile(TasksListParamsSchema); export const validateTasksGetParams = lazyCompile(TasksGetParamsSchema); export const validateTasksCancelParams = lazyCompile(TasksCancelParamsSchema); @@ -1242,6 +1276,17 @@ export { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema, + TaskSuggestionSchema, + TaskSuggestionEventSchema, + TaskSuggestionResolutionSchema, + TaskSuggestionsAcceptParamsSchema, + TaskSuggestionsAcceptResultSchema, + TaskSuggestionsCreateParamsSchema, + TaskSuggestionsCreateResultSchema, + TaskSuggestionsDismissParamsSchema, + TaskSuggestionsDismissResultSchema, + TaskSuggestionsListParamsSchema, + TaskSuggestionsListResultSchema, TaskSummarySchema, TasksListParamsSchema, TasksListResultSchema, @@ -1631,6 +1676,17 @@ export type { AuditEvent, AuditListParams, AuditListResult, + TaskSuggestion, + TaskSuggestionEvent, + TaskSuggestionResolution, + TaskSuggestionsAcceptParams, + TaskSuggestionsAcceptResult, + TaskSuggestionsCreateParams, + TaskSuggestionsCreateResult, + TaskSuggestionsDismissParams, + TaskSuggestionsDismissResult, + TaskSuggestionsListParams, + TaskSuggestionsListResult, TaskSummary, TasksListParams, TasksListResult, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index af3c56261fd..0acbc3ade89 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -28,6 +28,7 @@ export * from "./schema/secrets.js"; export * from "./schema/sessions.js"; export * from "./schema/snapshot.js"; export * from "./schema/system-info.js"; +export * from "./schema/task-suggestions.js"; export * from "./schema/tasks.js"; export * from "./schema/terminal.js"; export * from "./schema/types.js"; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 5a271bb673a..01bf28651f9 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -321,6 +321,19 @@ import { } from "./sessions.js"; import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js"; import { SystemInfoParamsSchema, SystemInfoResultSchema } from "./system-info.js"; +import { + TaskSuggestionEventSchema, + TaskSuggestionResolutionSchema, + TaskSuggestionSchema, + TaskSuggestionsAcceptParamsSchema, + TaskSuggestionsAcceptResultSchema, + TaskSuggestionsCreateParamsSchema, + TaskSuggestionsCreateResultSchema, + TaskSuggestionsDismissParamsSchema, + TaskSuggestionsDismissResultSchema, + TaskSuggestionsListParamsSchema, + TaskSuggestionsListResultSchema, +} from "./task-suggestions.js"; import { TasksCancelParamsSchema, TasksCancelResultSchema, @@ -486,6 +499,17 @@ export const ProtocolSchemas = { AuditEvent: AuditEventSchema, AuditListParams: AuditListParamsSchema, AuditListResult: AuditListResultSchema, + TaskSuggestion: TaskSuggestionSchema, + TaskSuggestionEvent: TaskSuggestionEventSchema, + TaskSuggestionResolution: TaskSuggestionResolutionSchema, + TaskSuggestionsAcceptParams: TaskSuggestionsAcceptParamsSchema, + TaskSuggestionsAcceptResult: TaskSuggestionsAcceptResultSchema, + TaskSuggestionsCreateParams: TaskSuggestionsCreateParamsSchema, + TaskSuggestionsCreateResult: TaskSuggestionsCreateResultSchema, + TaskSuggestionsDismissParams: TaskSuggestionsDismissParamsSchema, + TaskSuggestionsDismissResult: TaskSuggestionsDismissResultSchema, + TaskSuggestionsListParams: TaskSuggestionsListParamsSchema, + TaskSuggestionsListResult: TaskSuggestionsListResultSchema, TaskSummary: TaskSummarySchema, TasksListParams: TasksListParamsSchema, TasksListResult: TasksListResultSchema, diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 0fc13a74381..8259def971a 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -256,6 +256,13 @@ export const SessionsCreateParamsSchema = Type.Object( task: Type.Optional(Type.String()), message: Type.Optional(Type.String()), worktree: Type.Optional(Type.Boolean()), + cwd: Type.Optional( + Type.String({ + minLength: 1, + description: + "Absolute source directory for a managed worktree. Requires worktree=true and operator.admin.", + }), + ), }, { additionalProperties: false }, ); diff --git a/packages/gateway-protocol/src/schema/task-suggestions.ts b/packages/gateway-protocol/src/schema/task-suggestions.ts new file mode 100644 index 00000000000..470c725bb34 --- /dev/null +++ b/packages/gateway-protocol/src/schema/task-suggestions.ts @@ -0,0 +1,104 @@ +// Gateway Protocol schema module defines ephemeral follow-up task suggestions. +import { Type } from "typebox"; + +const TaskIdSchema = Type.String({ minLength: 1, maxLength: 128 }); +const TaskTitleSchema = Type.String({ minLength: 1, maxLength: 60 }); +const TaskPromptSchema = Type.String({ minLength: 1, maxLength: 32_768 }); +const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024 }); +const TaskCwdSchema = Type.String({ minLength: 1, maxLength: 4_096 }); +const TaskSessionKeySchema = Type.String({ minLength: 1, maxLength: 512 }); +const TaskAgentIdSchema = Type.String({ minLength: 1, maxLength: 128 }); + +/** One model-proposed follow-up task waiting for operator action. */ +export const TaskSuggestionSchema = Type.Object( + { + id: TaskIdSchema, + title: TaskTitleSchema, + prompt: TaskPromptSchema, + tldr: TaskTldrSchema, + cwd: TaskCwdSchema, + sessionKey: TaskSessionKeySchema, + agentId: Type.Optional(TaskAgentIdSchema), + createdAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); + +/** Lists pending suggestions, optionally narrowed to one source session. */ +export const TaskSuggestionsListParamsSchema = Type.Object( + { + sessionKey: Type.Optional(TaskSessionKeySchema), + agentId: Type.Optional(TaskAgentIdSchema), + }, + { additionalProperties: false }, +); + +export const TaskSuggestionsListResultSchema = Type.Object( + { suggestions: Type.Array(TaskSuggestionSchema) }, + { additionalProperties: false }, +); + +/** Creates a pending suggestion without starting any work. */ +export const TaskSuggestionsCreateParamsSchema = Type.Object( + { + title: TaskTitleSchema, + prompt: TaskPromptSchema, + tldr: TaskTldrSchema, + cwd: TaskCwdSchema, + sessionKey: TaskSessionKeySchema, + agentId: Type.Optional(TaskAgentIdSchema), + }, + { additionalProperties: false }, +); + +export const TaskSuggestionsCreateResultSchema = Type.Object( + { taskId: TaskIdSchema, suggestion: TaskSuggestionSchema }, + { additionalProperties: false }, +); + +export const TaskSuggestionResolutionSchema = Type.Union([ + Type.Literal("dismissed"), + Type.Literal("accepted"), + Type.Literal("expired"), +]); + +/** Atomically claims a pending suggestion and starts its server-owned worktree session. */ +export const TaskSuggestionsAcceptParamsSchema = Type.Object( + { taskId: TaskIdSchema }, + { additionalProperties: false }, +); + +export const TaskSuggestionsAcceptResultSchema = Type.Object( + { taskId: TaskIdSchema, key: TaskSessionKeySchema }, + { additionalProperties: false }, +); + +/** Removes a pending suggestion without starting work. */ +export const TaskSuggestionsDismissParamsSchema = Type.Object( + { + taskId: TaskIdSchema, + reason: Type.Optional(Type.String({ maxLength: 1_024 })), + }, + { additionalProperties: false }, +); + +export const TaskSuggestionsDismissResultSchema = Type.Object( + { taskId: TaskIdSchema, dismissed: Type.Boolean() }, + { additionalProperties: false }, +); + +/** Live update emitted when a pending suggestion is created or resolved. */ +export const TaskSuggestionEventSchema = Type.Union([ + Type.Object( + { action: Type.Literal("created"), suggestion: TaskSuggestionSchema }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal("resolved"), + taskId: TaskIdSchema, + resolution: TaskSuggestionResolutionSchema, + }, + { additionalProperties: false }, + ), +]); diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 8ca4e32a5f3..79de42d2ef5 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -33,6 +33,17 @@ export type EnvironmentsStatusParams = SchemaType<"EnvironmentsStatusParams">; export type EnvironmentsStatusResult = SchemaType<"EnvironmentsStatusResult">; export type SystemInfoParams = SchemaType<"SystemInfoParams">; export type SystemInfoResult = SchemaType<"SystemInfoResult">; +export type TaskSuggestion = SchemaType<"TaskSuggestion">; +export type TaskSuggestionEvent = SchemaType<"TaskSuggestionEvent">; +export type TaskSuggestionResolution = SchemaType<"TaskSuggestionResolution">; +export type TaskSuggestionsAcceptParams = SchemaType<"TaskSuggestionsAcceptParams">; +export type TaskSuggestionsAcceptResult = SchemaType<"TaskSuggestionsAcceptResult">; +export type TaskSuggestionsCreateParams = SchemaType<"TaskSuggestionsCreateParams">; +export type TaskSuggestionsCreateResult = SchemaType<"TaskSuggestionsCreateResult">; +export type TaskSuggestionsDismissParams = SchemaType<"TaskSuggestionsDismissParams">; +export type TaskSuggestionsDismissResult = SchemaType<"TaskSuggestionsDismissResult">; +export type TaskSuggestionsListParams = SchemaType<"TaskSuggestionsListParams">; +export type TaskSuggestionsListResult = SchemaType<"TaskSuggestionsListResult">; export type WorktreeRecord = SchemaType<"WorktreeRecord">; export type WorktreesListParams = SchemaType<"WorktreesListParams">; export type WorktreesListResult = SchemaType<"WorktreesListResult">; diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index b40191715c8..c2cab4287cc 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -3,6 +3,7 @@ "ios": { "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", "session.tool": "Session tool stream is not rendered by the iOS chat surface yet.", + "task.suggestion": "Task suggestion cards are a Control UI-only surface; iOS does not render them.", "sessions.changed": "iOS refreshes session lists via explicit sessions.* requests instead of this push event.", "presence": "Presence roster is a control-UI (web/desktop) surface; iOS does not render it.", "shutdown": "iOS relies on socket close plus reconnect/backoff instead of the shutdown notice.", @@ -23,6 +24,7 @@ "android": { "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", "session.tool": "Session tool stream is not rendered by the Android chat surface yet.", + "task.suggestion": "Task suggestion cards are a Control UI-only surface; Android does not render them.", "presence": "Presence roster is a control-UI (web/desktop) surface; Android does not render it.", "talk.mode": "Android toggles talk mode locally; gateway talk.mode sync is not consumed.", "shutdown": "Android relies on socket close plus reconnect/backoff instead of the shutdown notice.", diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index fde3caa6fe4..b0140fc7ec4 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -599,6 +599,42 @@ describe("createOpenClawCodingTools", () => { expect(latestCreateOpenClawToolsOptions().sourceReplyDeliveryMode).toBe("message_tool_only"); }); + it("uses the canonical spawn workspace for follow-up task suggestions", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + const sandboxDir = "/sandbox/workspace"; + + createOpenClawCodingTools({ + sandbox: createAgentToolsSandboxContext({ + workspaceDir: sandboxDir, + workspaceAccess: "ro", + fsBridge: createHostSandboxFsBridge(sandboxDir), + }), + workspaceDir: "/agent/workspace", + cwd: sandboxDir, + spawnWorkspaceDir: "/host/project", + }); + + expect(latestCreateOpenClawToolsOptions()).toMatchObject({ + workspaceDir: "/agent/workspace", + spawnWorkspaceDir: "/host/project", + cwd: "/host/project", + }); + }); + + it("keeps an unsandboxed task repo as the follow-up suggestion cwd", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + + createOpenClawCodingTools({ + workspaceDir: "/agent/workspace", + cwd: "/task/repo", + spawnWorkspaceDir: "/agent/workspace", + }); + + expect(latestCreateOpenClawToolsOptions().cwd).toBe("/task/repo"); + }); + it("skips unrelated tool families when construction is planned from a narrow allowlist", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 54e97ac117c..edb36055784 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -1008,6 +1008,11 @@ export function createOpenClawCodingTools(options?: { fsPolicy, workspaceDir: workspaceRoot, spawnWorkspaceDir: capabilityProfile.workspace.spawnWorkspaceRoot, + // Sandboxes execute against copied roots, but accepted suggestions create host + // worktrees. Unsandboxed task-repo sessions must stay on their runtime cwd. + cwd: sandbox + ? (capabilityProfile.workspace.spawnWorkspaceRoot ?? runtimeRoot) + : runtimeRoot, sandboxed: Boolean(sandbox), config: options?.config, pluginToolAllowlist, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts index 8f9cc4e11a7..334f49a5e72 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts @@ -349,6 +349,23 @@ describe("resolveEmbeddedAttemptToolConstructionPlan", () => { }, }, ); + for (const toolName of ["spawn_task", "dismiss_task"]) { + expectConstructionPlan( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: [toolName] }), + { + constructTools: true, + includeCoreTools: true, + runtimeToolAllowlist: [toolName], + coding: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: false, + }, + }, + ); + } }); it("keeps plugin-owned catalog tools on the plugin construction path", () => { diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index 65c04631af8..0d2921e01f2 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -41,11 +41,13 @@ const OPENCLAW_TOOL_FACTORY_NAMES = new Set([ "sessions_spawn", "sessions_yield", "skill_workshop", + "spawn_task", "create_goal", "subagents", "tts", "update_goal", "update_plan", + "dismiss_task", "video_generate", "web_fetch", "web_search", diff --git a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts index 79d3c872ce4..29abe6a481f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts @@ -122,4 +122,29 @@ describe("runEmbeddedAttempt cwd/workspace split", () => { ).rejects.toThrow("cwd override is not supported"); expect(hoisted.createOpenClawCodingToolsMock).not.toHaveBeenCalled(); }); + + it("runs a managed worktree when sandbox workspace and cwd match", async () => { + const worktree = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-worktree-")); + tempPaths.push(worktree); + hoisted.resolveSandboxContextMock.mockResolvedValueOnce({ + enabled: true, + workspaceAccess: "rw", + workspaceDir: worktree, + }); + + await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey: "agent:main:dashboard:worktree", + tempPaths, + attemptOverrides: { + workspaceDir: worktree, + cwd: worktree, + disableTools: false, + }, + }); + + expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ cwd: worktree, workspaceDir: worktree }), + ); + }); }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 65c966f1cc3..72dbcf1b98c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -66,6 +66,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js"; import { createSkillWorkshopTool } from "./tools/skill-workshop-tool.js"; import { createSubagentsTool } from "./tools/subagents-tool.js"; +import { createTaskSuggestionTools } from "./tools/task-suggestion-tools.js"; import { createTranscriptsTool } from "./tools/transcripts-tool.js"; import { createTtsTool } from "./tools/tts-tool.js"; import { createUpdatePlanTool } from "./tools/update-plan-tool.js"; @@ -189,6 +190,8 @@ export function createOpenClawTools( * subagents inherit the real workspace path instead of the sandbox copy. */ spawnWorkspaceDir?: string; + /** Current runtime directory used as the default project for follow-up suggestions. */ + cwd?: string; /** Callback invoked when sessions_yield tool is called. */ onYield?: (message: string) => Promise | void; /** Allow plugin tools for this tool set to late-bind the gateway subagent. */ @@ -217,6 +220,9 @@ export function createOpenClawTools( const spawnWorkspaceDir = resolveWorkspaceRoot( options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir, ); + const runtimeCwd = resolveWorkspaceRoot( + options?.cwd ?? options?.workspaceDir ?? inferredWorkspaceDir, + ); options?.recordToolPrepStage?.("openclaw-tools:session-workspace"); const deliveryContext = normalizeDeliveryContext({ channel: options?.agentChannel, @@ -253,6 +259,9 @@ export function createOpenClawTools( const skillWorkshopMessageId = normalizeOptionalString( options?.currentMessageId === undefined ? undefined : String(options.currentMessageId), ); + const taskSuggestionSessionKey = normalizeOptionalString( + options?.runSessionKey ?? options?.agentSessionKey, + ); const imageToolAgentDir = options?.agentDir; const imageTool = resolveImageToolFactoryAvailable({ config: availabilityConfig ?? resolvedConfig, @@ -445,6 +454,13 @@ export function createOpenClawTools( : {}), }), ]), + ...(!embedded && taskSuggestionSessionKey + ? createTaskSuggestionTools({ + sessionKey: taskSuggestionSessionKey, + agentId: sessionAgentId, + cwd: runtimeCwd, + }) + : []), ...(messageTool && includeMessageTool ? [messageTool] : []), ...collectPresentOpenClawTools([heartbeatTool]), createTtsTool({ diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index e19eef40a00..c6e928b712c 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -126,6 +126,22 @@ describe("openclaw-tools update_plan gating", () => { expect(enabledTools).toContain("transcripts"); }); + it("registers task suggestions for gateway-backed sessions", () => { + const withoutSession = createFastToolNames({ + config: {} as OpenClawConfig, + cwd: "/repo", + }); + const withSession = createFastToolNames({ + config: {} as OpenClawConfig, + agentSessionKey: "agent:main:main", + cwd: "/repo", + }); + + expect(withoutSession).not.toContain("spawn_task"); + expect(withoutSession).not.toContain("dismiss_task"); + expect(withSession).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"])); + }); + it("keeps explicitly allowed message tool in embedded completions", () => { setEmbeddedMode(true); const fromRuntimeAllowlist = createOpenClawTools({ diff --git a/src/agents/spawned-context.test.ts b/src/agents/spawned-context.test.ts index 00b01adb612..0f3b754d57c 100644 --- a/src/agents/spawned-context.test.ts +++ b/src/agents/spawned-context.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { mapToolContextToSpawnedRunMetadata, normalizeSpawnedRunMetadata, - resolveIngressWorkspaceOverrideForSpawnedRun, + resolveIngressWorkspaceOverrideForSessionRun, resolveSpawnedWorkspaceInheritance, } from "./spawned-context.js"; @@ -93,19 +93,22 @@ describe("resolveSpawnedWorkspaceInheritance", () => { }); }); -describe("resolveIngressWorkspaceOverrideForSpawnedRun", () => { - it("forwards workspace only for spawned runs", () => { +describe("resolveIngressWorkspaceOverrideForSessionRun", () => { + it("uses inherited workspaces for spawned runs and managed cwd for dashboard worktrees", () => { expect( - resolveIngressWorkspaceOverrideForSpawnedRun({ + resolveIngressWorkspaceOverrideForSessionRun({ spawnedBy: "agent:main:subagent:parent", workspaceDir: "/tmp/ws", + cwd: "/tmp/task", }), ).toBe("/tmp/ws"); expect( - resolveIngressWorkspaceOverrideForSpawnedRun({ + resolveIngressWorkspaceOverrideForSessionRun({ spawnedBy: "", workspaceDir: "/tmp/ws", + cwd: "/tmp/worktree", }), - ).toBeUndefined(); + ).toBe("/tmp/worktree"); + expect(resolveIngressWorkspaceOverrideForSessionRun()).toBeUndefined(); }); }); diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 52c69c9f87b..aedb9417eac 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -79,10 +79,19 @@ export function resolveSpawnedWorkspaceInheritance(params: { return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined; } -/** Return a spawned run's ingress workspace override only for child runs. */ -export function resolveIngressWorkspaceOverrideForSpawnedRun( - metadata?: Pick | null, +/** Resolve the persisted workspace used when a session re-enters an agent runtime. */ +export function resolveIngressWorkspaceOverrideForSessionRun( + metadata?: + | (Pick & { + cwd?: string | null; + }) + | null, ): string | undefined { const normalized = normalizeSpawnedRunMetadata(metadata); - return normalized.spawnedBy ? normalized.workspaceDir : undefined; + if (normalized.spawnedBy) { + return normalized.workspaceDir; + } + // Dashboard worktree sessions are not subagents, so their managed cwd is + // also the workspace that sandbox setup must mount on every later turn. + return normalizeOptionalString(metadata?.cwd); } diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index fa9ebf6e89c..7110e31d934 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -44,6 +44,8 @@ describe("tool-catalog", () => { "sessions_yield", "subagents", "session_status", + "spawn_task", + "dismiss_task", "cron", "get_goal", "create_goal", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index c55b8c8d57e..2648515928d 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -12,6 +12,8 @@ import { SESSIONS_SEND_TOOL_DISPLAY_SUMMARY, SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY, SESSION_STATUS_TOOL_DISPLAY_SUMMARY, + SPAWN_TASK_TOOL_DISPLAY_SUMMARY, + DISMISS_TASK_TOOL_DISPLAY_SUMMARY, UPDATE_PLAN_TOOL_DISPLAY_SUMMARY, } from "./tool-description-presets.js"; @@ -204,6 +206,22 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["minimal", "coding", "messaging"], includeInOpenClawGroup: true, }, + { + id: "spawn_task", + label: "spawn_task", + description: SPAWN_TASK_TOOL_DISPLAY_SUMMARY, + sectionId: "sessions", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "dismiss_task", + label: "dismiss_task", + description: DISMISS_TASK_TOOL_DISPLAY_SUMMARY, + sectionId: "sessions", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "browser", label: "browser", diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index be4bbda61ab..c25a8d6abce 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -10,6 +10,8 @@ export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn subagent or ACP sessio export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn subagent session."; export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status/model/usage."; export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track short work plan."; +export const SPAWN_TASK_TOOL_DISPLAY_SUMMARY = "Suggest follow-up work for operator approval."; +export const DISMISS_TASK_TOOL_DISPLAY_SUMMARY = "Withdraw a pending task suggestion."; /** Describes the sessions_list tool for model-facing instructions. */ export function describeSessionsListTool(): string { diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index ebd10b9cf81..45212f544ef 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -285,6 +285,16 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Update Plan", detailKeys: ["explanation", "plan.0.step"], }, + spawn_task: { + emoji: "✨", + title: "Suggest Task", + detailKeys: ["title", "tldr", "cwd"], + }, + dismiss_task: { + emoji: "🗑️", + title: "Dismiss Task", + detailKeys: ["task_id", "reason"], + }, skill_workshop: { emoji: "🧰", title: "Skill Workshop", diff --git a/src/agents/tools/task-suggestion-tools.test.ts b/src/agents/tools/task-suggestion-tools.test.ts new file mode 100644 index 00000000000..83b76969f7e --- /dev/null +++ b/src/agents/tools/task-suggestion-tools.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTaskSuggestionTools } from "./task-suggestion-tools.js"; + +function createTools(gatewayCall = vi.fn()) { + return { + gatewayCall, + tools: createTaskSuggestionTools({ + sessionKey: "agent:main:main", + agentId: "main", + cwd: "/repo", + callGateway: gatewayCall as never, + }), + }; +} + +describe("task suggestion tools", () => { + it("creates a suggestion without starting work", async () => { + const { gatewayCall, tools } = createTools( + vi.fn(async () => ({ taskId: "task_123", suggestion: {} })), + ); + const spawnTask = tools.find((tool) => tool.name === "spawn_task"); + + const result = await spawnTask?.execute("call-1", { + title: "Remove stale adapter", + prompt: "Delete the unused adapter in src/example.ts and update its tests.", + tldr: "The adapter is no longer reachable. Removing it reduces maintenance cost.", + }); + + expect(gatewayCall).toHaveBeenCalledWith( + "taskSuggestions.create", + {}, + { + title: "Remove stale adapter", + prompt: "Delete the unused adapter in src/example.ts and update its tests.", + tldr: "The adapter is no longer reachable. Removing it reduces maintenance cost.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + }, + ); + expect(result?.content).toEqual([ + { type: "text", text: JSON.stringify({ task_id: "task_123" }, null, 2) }, + ]); + }); + + it("withdraws a pending suggestion", async () => { + const { gatewayCall, tools } = createTools( + vi.fn(async () => ({ taskId: "task_123", dismissed: true })), + ); + const dismissTask = tools.find((tool) => tool.name === "dismiss_task"); + + await dismissTask?.execute("call-2", { task_id: "task_123", reason: "Already fixed" }); + + expect(gatewayCall).toHaveBeenCalledWith( + "taskSuggestions.dismiss", + {}, + { taskId: "task_123", reason: "Already fixed" }, + ); + }); + + it("rejects relative project directories", async () => { + const { tools } = createTools(); + const spawnTask = tools.find((tool) => tool.name === "spawn_task"); + + await expect( + spawnTask?.execute("call-3", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is confirmed and untested.", + cwd: "relative/repo", + }), + ).rejects.toThrow("cwd must be an absolute path"); + }); +}); diff --git a/src/agents/tools/task-suggestion-tools.ts b/src/agents/tools/task-suggestion-tools.ts new file mode 100644 index 00000000000..5f51104fb02 --- /dev/null +++ b/src/agents/tools/task-suggestion-tools.ts @@ -0,0 +1,127 @@ +/** Model tools for proposing and withdrawing operator-approved follow-up work. */ +import path from "node:path"; +import { Type } from "typebox"; +import type { + TaskSuggestionsCreateResult, + TaskSuggestionsDismissResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + DISMISS_TASK_TOOL_DISPLAY_SUMMARY, + SPAWN_TASK_TOOL_DISPLAY_SUMMARY, +} from "../tool-description-presets.js"; +import { type AnyAgentTool, ToolInputError, jsonResult, readStringParam } from "./common.js"; +import { callGatewayTool } from "./gateway.js"; + +const SpawnTaskToolSchema = Type.Object( + { + title: Type.String({ + minLength: 1, + maxLength: 60, + description: "Imperative task title under 60 characters.", + }), + prompt: Type.String({ + minLength: 1, + maxLength: 32_768, + description: "Self-contained task prompt with relevant file paths and context.", + }), + tldr: Type.String({ + minLength: 1, + maxLength: 1_024, + description: "One or two plain-language sentences explaining the value; no code or paths.", + }), + cwd: Type.Optional( + Type.String({ + minLength: 1, + maxLength: 4_096, + description: "Absolute project directory; defaults to the current project.", + }), + ), + }, + { additionalProperties: false }, +); + +const DismissTaskToolSchema = Type.Object( + { + task_id: Type.String({ + minLength: 1, + maxLength: 128, + description: "ID returned by spawn_task.", + }), + reason: Type.Optional( + Type.String({ maxLength: 1_024, description: "Short reason the suggestion is stale." }), + ), + }, + { additionalProperties: false }, +); + +type GatewayCaller = typeof callGatewayTool; + +export function createTaskSuggestionTools(params: { + sessionKey: string; + agentId?: string; + cwd: string; + callGateway?: GatewayCaller; +}): AnyAgentTool[] { + const gatewayCall = params.callGateway ?? callGatewayTool; + return [ + { + label: "Suggest Task", + name: "spawn_task", + displaySummary: SPAWN_TASK_TOOL_DISPLAY_SUMMARY, + description: [ + "Suggest valuable follow-up work discovered during the current task.", + "Use only for confirmed, out-of-scope work such as dead code, stale docs, missing coverage, a verified TODO, or a security issue.", + "This creates an operator-facing suggestion only; it does not start work.", + ].join(" "), + parameters: SpawnTaskToolSchema, + execute: async (_toolCallId, args) => { + const input = args as Record; + const title = readStringParam(input, "title", { required: true }); + const prompt = readStringParam(input, "prompt", { required: true }); + const tldr = readStringParam(input, "tldr", { required: true }); + const cwd = readStringParam(input, "cwd") ?? params.cwd; + if (title.length > 60) { + throw new ToolInputError("title must be at most 60 characters"); + } + if (!path.isAbsolute(cwd)) { + throw new ToolInputError("cwd must be an absolute path"); + } + const result = await gatewayCall( + "taskSuggestions.create", + {}, + { + title, + prompt, + tldr, + cwd, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + }, + ); + return jsonResult({ task_id: result.taskId }); + }, + }, + { + label: "Dismiss Task", + name: "dismiss_task", + displaySummary: DISMISS_TASK_TOOL_DISPLAY_SUMMARY, + description: + "Withdraw a still-pending spawn_task suggestion that became stale or irrelevant. Suggestions already accepted by the operator cannot be withdrawn.", + parameters: DismissTaskToolSchema, + execute: async (_toolCallId, args) => { + const input = args as Record; + const taskId = readStringParam(input, "task_id", { required: true }); + const reason = readStringParam(input, "reason"); + const result = await gatewayCall( + "taskSuggestions.dismiss", + {}, + { + taskId, + ...(reason ? { reason } : {}), + }, + ); + return jsonResult({ task_id: taskId, dismissed: result.dismissed }); + }, + }, + ]; +} diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index a4cdf571c97..47cfced2ffa 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -459,6 +459,14 @@ export class ManagedWorktreeService { return findLiveRegistryWorktreeByOwner(this.env, ownerKind, ownerId); } + /** Resolves the canonical registry root and the caller's own checkout root. */ + async resolveRepositoryPaths( + repoRoot: string, + ): Promise<{ canonicalRoot: string; sourceRoot: string }> { + const resolved = await resolveRepository(repoRoot); + return { canonicalRoot: resolved.repoRoot, sourceRoot: resolved.sourceRoot }; + } + async acquire(id: string): Promise { const record = this.requireLiveRecord(id); const result = await runGit(record.repoRoot, [ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 2c31839db32..dbac85fbb78 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -17,7 +17,7 @@ import { resolveFastModeState } from "../../agents/fast-mode.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; -import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js"; +import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { updateAmbientTranscriptWatermark } from "../../config/sessions/ambient-transcript-watermark.js"; @@ -544,7 +544,7 @@ export async function runPreparedReply( sessionKey, sessionId, storePath, - workspaceDir, + workspaceDir: configuredWorkspaceDir, sessionEntryHandle, sessionStore, } = params; @@ -741,17 +741,19 @@ export async function runPreparedReply( rawBodyTrimmed.length > 0))); const startupAction = softResetTriggered || /^\/reset(?:\s|$)/i.test(normalizedCommandBody) ? "reset" : "new"; - const spawnedWorkspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({ + const sessionWorkspaceOverride = resolveIngressWorkspaceOverrideForSessionRun({ spawnedBy: sessionEntry?.spawnedBy, workspaceDir: sessionEntry?.spawnedWorkspaceDir, + cwd: sessionEntry?.spawnedCwd, }); + const workspaceDir = sessionWorkspaceOverride ?? configuredWorkspaceDir; const bareResetPromptState = isBareSessionReset && workspaceDir ? await resolveBareSessionResetPromptState({ cfg, workspaceDir, isPrimaryRun: !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey), - isCanonicalWorkspace: !spawnedWorkspaceOverride, + isCanonicalWorkspace: !sessionWorkspaceOverride, hasBootstrapFileAccess: () => resolveBareResetBootstrapFileAccess({ cfg, diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index f583de1ea38..2dcd92e3827 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -22,7 +22,7 @@ import { buildSandboxFsMounts } from "../agents/sandbox/fs-paths.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox/runtime-status.js"; import { resolveSandboxWorkspaceLayoutPaths } from "../agents/sandbox/shared.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; -import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../agents/spawned-context.js"; +import { resolveIngressWorkspaceOverrideForSessionRun } from "../agents/spawned-context.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import { getRuntimeConfig } from "../config/config.js"; import { @@ -198,9 +198,10 @@ export async function sandboxExplainCommand( // later turns keep running in the same location. Explain must mirror those // overrides or its effective paths point at a different runtime. const configuredWorkspaceDir = resolveAgentWorkspaceDir(cfg, resolvedAgentId); - const sessionWorkspaceDir = resolveIngressWorkspaceOverrideForSpawnedRun({ + const sessionWorkspaceDir = resolveIngressWorkspaceOverrideForSessionRun({ spawnedBy: sessionEntry?.spawnedBy, workspaceDir: sessionEntry?.spawnedWorkspaceDir, + cwd: sessionEntry?.spawnedCwd, }); const effectiveAgentWorkspaceDir = sessionWorkspaceDir ?? configuredWorkspaceDir; const directRuntimeCwd = diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index cb404913fb0..2caf9647471 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -44,6 +44,10 @@ describe("method scope resolution", () => { ["tasks.list", ["operator.read"]], ["audit.list", ["operator.read"]], ["tasks.get", ["operator.read"]], + ["taskSuggestions.list", ["operator.read"]], + ["taskSuggestions.create", ["operator.write"]], + ["taskSuggestions.accept", ["operator.admin"]], + ["taskSuggestions.dismiss", ["operator.write"]], ["config.schema.lookup", ["operator.read"]], ["sessions.create", ["operator.write"]], ["sessions.send", ["operator.write"]], @@ -205,6 +209,24 @@ describe("method scope resolution", () => { expect(isGatewayMethodClassified("sessions.patch")).toBe(true); }); + it("requires admin only when sessions.create targets an explicit cwd", () => { + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true }), + ).toEqual(["operator.write"]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { + worktree: true, + cwd: "/other/repo", + }), + ).toEqual(["operator.admin"]); + expect( + authorizeOperatorScopesForMethod("sessions.create", ["operator.write"], { + worktree: true, + cwd: "/other/repo", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + }); + it.each([ ["model", { key: "agent:main:ios-1", model: "anthropic/claude-sonnet-5" }], ["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }], diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f2fc8ce61c5..e789eb9236a 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -98,6 +98,13 @@ function resolveSessionsPatchRequiredScopes(params: unknown): OperatorScope[] { return safeOnly ? [WRITE_SCOPE] : [ADMIN_SCOPE]; } +function resolveSessionsCreateRequiredScopes(params: unknown): OperatorScope[] { + if (!params || typeof params !== "object" || Array.isArray(params)) { + return [WRITE_SCOPE]; + } + return Object.hasOwn(params, "cwd") ? [ADMIN_SCOPE] : [WRITE_SCOPE]; +} + function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[] | undefined { if (!params || typeof params !== "object" || Array.isArray(params)) { return undefined; @@ -150,6 +157,9 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod( if (method === "sessions.patch") { return resolveSessionsPatchRequiredScopes(params); } + if (method === "sessions.create") { + return resolveSessionsCreateRequiredScopes(params); + } if (method === "sessions.delete") { return resolveSessionsDeleteRequiredScopes(params); } @@ -220,6 +230,13 @@ export function authorizeOperatorScopesForMethod( return { allowed: true }; } if (isDynamicOperatorGatewayMethod(method)) { + if (method === "sessions.create") { + const missingScope = findMissingOperatorScope( + resolveSessionsCreateRequiredScopes(params), + scopes, + ); + return missingScope ? { allowed: false, missingScope } : { allowed: true }; + } if (method === "sessions.patch") { const missingScope = findMissingOperatorScope( resolveSessionsPatchRequiredScopes(params), diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 1c3b204944a..303a67751b6 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -101,6 +101,10 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "tasks.list", scope: "operator.read" }, { name: "tasks.get", scope: "operator.read" }, { name: "tasks.cancel", scope: "operator.write" }, + { name: "taskSuggestions.list", scope: "operator.read" }, + { name: "taskSuggestions.create", scope: "operator.write" }, + { name: "taskSuggestions.accept", scope: "operator.admin" }, + { name: "taskSuggestions.dismiss", scope: "operator.write" }, { name: "environments.list", scope: "operator.read" }, { name: "environments.status", scope: "operator.read" }, { name: "worktrees.list", scope: "operator.read" }, @@ -163,7 +167,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "sessions.compaction.get", scope: "operator.read" }, { name: "sessions.compaction.branch", scope: "operator.write" }, { name: "sessions.compaction.restore", scope: "operator.admin" }, - { name: "sessions.create", scope: "operator.write", startup: true }, + // Params-aware: explicit cwd can point at any host checkout and requires admin. + { name: "sessions.create", scope: "dynamic", startup: true }, { name: "sessions.send", scope: "operator.write", startup: true }, { name: "sessions.abort", scope: "operator.write", startup: true }, // Params-aware: write scope may mutate chat-organization fields diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 374e1953560..33262f90a16 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -38,6 +38,7 @@ const EVENT_SCOPE_GUARDS: Record = { "talk.event": [READ_SCOPE], "talk.mode": [WRITE_SCOPE], task: [READ_SCOPE], + "task.suggestion": [READ_SCOPE], "update.available": [], "voicewake.changed": [READ_SCOPE], "voicewake.routing.changed": [READ_SCOPE], diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 8256a9f69e1..014e4c2fb25 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -53,6 +53,7 @@ export const GATEWAY_EVENTS = [ "heartbeat", "cron", "task", + "task.suggestion", "node.pair.requested", "node.pair.resolved", "node.invoke.request", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index a8ddcd6dc90..1b73fd78409 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -214,6 +214,10 @@ const loadTasksHandlers = lazyHandlerModule( () => import("./server-methods/tasks.js"), (module) => module.tasksHandlers, ); +const loadTaskSuggestionsHandlers = lazyHandlerModule( + () => import("./server-methods/task-suggestions.js"), + (module) => module.taskSuggestionsHandlers, +); const loadToolsCatalogHandlers = lazyHandlerModule( () => import("./server-methods/tools-catalog.js"), (module) => module.toolsCatalogHandlers, @@ -498,6 +502,15 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["tasks.list", "tasks.get", "tasks.cancel"], loadHandlers: loadTasksHandlers, }), + ...createLazyCoreHandlers({ + methods: [ + "taskSuggestions.list", + "taskSuggestions.create", + "taskSuggestions.accept", + "taskSuggestions.dismiss", + ], + loadHandlers: loadTaskSuggestionsHandlers, + }), ...createLazyCoreHandlers({ methods: ["tools.catalog"], loadHandlers: loadToolsCatalogHandlers, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index a0a38c67060..6acb4c04878 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -3668,6 +3668,35 @@ describe("gateway agent handler", () => { expect(spawnedCall.cwd).toBe("/tmp/task-repo"); }); + it("uses a managed dashboard worktree as both workspace and runtime cwd", async () => { + primeMainAgentRun(); + mockMainSessionEntry({ spawnedCwd: "/tmp/session-worktree" }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + "agent:main:main": buildExistingMainStoreEntry({ + spawnedCwd: "/tmp/session-worktree", + }), + }; + return await updater(store); + }); + mocks.agentCommand.mockClear(); + + await invokeAgent( + { + message: "worktree run", + sessionKey: "agent:main:main", + idempotencyKey: "worktree-workspace-forwarded", + }, + { reqId: "worktree-workspace-forwarded-1" }, + ); + const worktreeCall = await waitForAgentCommandCall<{ + cwd?: string; + workspaceDir?: string; + }>(); + expect(worktreeCall.workspaceDir).toBe("/tmp/session-worktree"); + expect(worktreeCall.cwd).toBe("/tmp/session-worktree"); + }); + it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => { mockMainSessionEntry({ sessionId: "existing-session-id", diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 1e19fa6911c..59e34e79ad9 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -60,7 +60,7 @@ import { } from "../../agents/run-timeout-attribution.js"; import { normalizeSpawnedRunMetadata, - resolveIngressWorkspaceOverrideForSpawnedRun, + resolveIngressWorkspaceOverrideForSessionRun, } from "../../agents/spawned-context.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommandFromIngress } from "../../commands/agent.js"; @@ -3368,9 +3368,10 @@ export const agentHandlers: GatewayRequestHandlers = { } }, // Internal-only: allow workspace override for spawned subagent runs. - workspaceDir: resolveIngressWorkspaceOverrideForSpawnedRun({ + workspaceDir: resolveIngressWorkspaceOverrideForSessionRun({ spawnedBy: spawnedByValue, workspaceDir: sessionEntry?.spawnedWorkspaceDir, + cwd: sessionEntry?.spawnedCwd, }), cwd: resolveSessionRuntimeCwd({ requestedCwd: request.cwd, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index cc5ab2f55fe..f70629bcf24 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -35,6 +35,7 @@ import { import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; import { abortEmbeddedAgentRun, isEmbeddedAgentRunActive, @@ -1192,15 +1193,32 @@ export const sessionsHandlers: GatewayRequestHandlers = { const p = params; const cfg = context.getRuntimeConfig(); const initialMessage = resolveOptionalInitialSessionMessage(p); + const requestedCwd = normalizeOptionalString(p.cwd); + if (requestedCwd && p.worktree !== true) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd requires worktree=true"), + ); + return; + } + if (requestedCwd && !path.isAbsolute(requestedCwd)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd must be absolute"), + ); + return; + } let sessionKey = p.key; let sessionAgentId = p.agentId; let sessionWorktree: Awaited> | undefined; let sessionCwd: string | undefined; + let sessionSourceRoot: string | undefined; let provisionedSessionWorktree = false; if (p.worktree === true) { - // Session worktrees stay at the method's operator.write bar: unlike worktrees.create, - // this path never takes an arbitrary repoRoot — it only checks out the agent's own - // configured workspace, the same repo chat runs already mutate for write-scope clients. + // The normal path stays at operator.write and checks out the configured agent workspace. + // An explicit cwd can target another host checkout, so method-scopes requires admin. const explicitKey = normalizeOptionalString(p.key); const requestedKey = explicitKey ?? "global"; const requestedAgent = resolveRequestedGlobalAgentId(cfg, requestedKey, p.agentId); @@ -1243,7 +1261,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const target = resolveGatewaySessionStoreTarget({ cfg, key: targetKey, agentId }); sessionKey = preservesUnspecifiedKey ? undefined : targetKey; sessionAgentId = target.agentId; - const workspace = resolveAgentWorkspaceDir(cfg, target.agentId); + const workspace = requestedCwd ?? resolveAgentWorkspaceDir(cfg, target.agentId); // Subdirectory workspaces are valid: the worktree service resolves the repo root // via git discovery, so the preflight must accept ancestor .git entries too. if (!insideGitCheckout(workspace)) { @@ -1255,6 +1273,8 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } try { + const requestedRepository = await managedWorktrees.resolveRepositoryPaths(workspace); + sessionSourceRoot = requestedRepository.sourceRoot; const existing = managedWorktrees.findLiveByOwner("session", target.canonicalKey); let existingDirectory = false; if (existing) { @@ -1265,6 +1285,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } if (existing && existingDirectory) { + if (existing.repoRoot !== requestedRepository.canonicalRoot) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "session worktree belongs to a different repository", + ), + ); + return; + } sessionWorktree = existing; } else { const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; @@ -1288,7 +1319,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { sessionCwd = sessionWorktree.path; try { const relative = path.relative( - fs.realpathSync(sessionWorktree.repoRoot), + sessionSourceRoot ?? fs.realpathSync(sessionWorktree.repoRoot), fs.realpathSync(workspace), ); if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { @@ -2817,7 +2848,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { const resolvedModel = resolveSessionModelRef(cfg, latestEntry, target.agentId); const workspaceDir = - normalizeOptionalString(latestEntry.spawnedWorkspaceDir) || + resolveIngressWorkspaceOverrideForSessionRun({ + spawnedBy: latestEntry.spawnedBy, + workspaceDir: latestEntry.spawnedWorkspaceDir, + cwd: latestEntry.spawnedCwd, + }) ?? resolveAgentWorkspaceDir(cfg, target.agentId); const operationId = randomUUID(); emitSessionOperation(context, { diff --git a/src/gateway/server-methods/task-suggestions.test.ts b/src/gateway/server-methods/task-suggestions.test.ts new file mode 100644 index 00000000000..1c5cbd7a7df --- /dev/null +++ b/src/gateway/server-methods/task-suggestions.test.ts @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { + MAX_TASK_SUGGESTION_RETAINED_BYTES, + beginTaskSuggestionAcceptance, + resetTaskSuggestionsForTest, +} from "../task-suggestion-registry.js"; +import { sessionsHandlers } from "./sessions.js"; +import { taskSuggestionsHandlers } from "./task-suggestions.js"; +import type { RespondFn } from "./types.js"; + +type Method = + | "taskSuggestions.list" + | "taskSuggestions.create" + | "taskSuggestions.accept" + | "taskSuggestions.dismiss"; + +async function call(method: Method, params: Record) { + const calls: Parameters[] = []; + const broadcast = vi.fn(); + const respond: RespondFn = (...args) => { + calls.push(args); + }; + await taskSuggestionsHandlers[method]?.({ + params, + respond, + context: { broadcast, getRuntimeConfig: () => ({}) }, + } as never); + return { response: calls[0], broadcast }; +} + +function requirePayload(result: Awaited>): unknown { + expect(result.response?.[0]).toBe(true); + if (!result.response?.[0]) { + throw new Error("expected a successful gateway response"); + } + return result.response[1]; +} + +beforeEach(() => resetTaskSuggestionsForTest()); +afterEach(() => vi.restoreAllMocks()); + +describe("task suggestion gateway methods", () => { + it("creates, lists, and resolves an ephemeral suggestion", async () => { + const created = await call("taskSuggestions.create", { + title: "Remove stale adapter", + prompt: "Delete src/example.ts and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + const payload = requirePayload(created) as { taskId: string }; + expect(payload.taskId).toMatch(/^task_/); + expect(created.broadcast).toHaveBeenCalledWith( + "task.suggestion", + expect.objectContaining({ + action: "created", + suggestion: expect.objectContaining({ agentId: "main" }), + }), + { dropIfSlow: true }, + ); + + const listed = await call("taskSuggestions.list", { + sessionKey: "agent:main:main", + agentId: "main", + }); + expect(listed.response?.[1]).toMatchObject({ + suggestions: [{ id: payload.taskId, cwd: "/repo" }], + }); + + const resolved = await call("taskSuggestions.dismiss", { + taskId: payload.taskId, + }); + expect(resolved.response?.[1]).toEqual({ taskId: payload.taskId, dismissed: true }); + expect(resolved.broadcast).toHaveBeenCalledWith( + "task.suggestion", + { action: "resolved", taskId: payload.taskId, resolution: "dismissed" }, + { dropIfSlow: true }, + ); + + const empty = await call("taskSuggestions.list", {}); + expect(empty.response?.[1]).toEqual({ suggestions: [] }); + }); + + it("accepts a suggestion once and replays the created session key", async () => { + const created = await call("taskSuggestions.create", { + title: "Remove stale adapter", + prompt: "Delete src/example.ts and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + let sessionKey = ""; + const createSession = vi + .spyOn(sessionsHandlers, "sessions.create") + .mockImplementation(async ({ params, respond }) => { + expect(params).toMatchObject({ + agentId: "main", + parentSessionKey: "agent:main:main", + label: "Remove stale adapter", + task: "Delete src/example.ts and update its tests.", + worktree: true, + cwd: "/repo", + }); + sessionKey = (params as { key: string }).key; + expect(sessionKey).toMatch(/^agent:main:dashboard:/); + respond(true, { key: sessionKey, runStarted: true }, undefined); + }); + + const first = await call("taskSuggestions.accept", { taskId }); + const retry = await call("taskSuggestions.accept", { taskId }); + + expect(first.response?.[1]).toEqual({ taskId, key: sessionKey }); + expect(retry.response?.[1]).toEqual({ taskId, key: sessionKey }); + expect(createSession).toHaveBeenCalledTimes(1); + expect(first.broadcast).toHaveBeenCalledWith( + "task.suggestion", + { action: "resolved", taskId, resolution: "accepted" }, + { dropIfSlow: true }, + ); + }); + + it("coalesces concurrent acceptance requests", async () => { + const created = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const createSession = vi + .spyOn(sessionsHandlers, "sessions.create") + .mockImplementation(async ({ params, respond }) => { + await gate; + respond(true, { key: (params as { key: string }).key, runStarted: true }, undefined); + }); + + const first = call("taskSuggestions.accept", { taskId }); + await vi.waitFor(() => expect(createSession).toHaveBeenCalledTimes(1)); + const second = call("taskSuggestions.accept", { taskId }); + release?.(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.response?.[1]).toEqual(secondResult.response?.[1]); + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it("rolls back an empty session and keeps a failed seed suggestion pending", async () => { + const created = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + let sessionKey = ""; + vi.spyOn(sessionsHandlers, "sessions.create").mockImplementation( + async ({ params, respond }) => { + sessionKey = (params as { key: string }).key; + respond( + true, + { + key: sessionKey, + runStarted: false, + runError: { message: "provider unavailable" }, + }, + undefined, + ); + }, + ); + const deleteSession = vi + .spyOn(sessionsHandlers, "sessions.delete") + .mockImplementation(async ({ params, respond }) => { + expect(params).toEqual({ + key: sessionKey, + agentId: "main", + deleteTranscript: true, + emitLifecycleHooks: false, + }); + respond(true, { ok: true, deleted: true }, undefined); + }); + + const accepted = await call("taskSuggestions.accept", { taskId }); + const listed = await call("taskSuggestions.list", {}); + + expect(accepted.response?.[0]).toBe(false); + expect(accepted.response?.[2]).toMatchObject({ message: "provider unavailable" }); + expect(deleteSession).toHaveBeenCalledTimes(1); + expect(accepted.broadcast).toHaveBeenCalledWith( + "task.suggestion", + expect.objectContaining({ + action: "created", + suggestion: expect.objectContaining({ id: taskId }), + }), + { dropIfSlow: true }, + ); + expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] }); + }); + + it("rolls back a preallocated session when creation throws after persistence", async () => { + const created = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + let sessionKey = ""; + vi.spyOn(sessionsHandlers, "sessions.create").mockImplementation(async ({ params }) => { + sessionKey = (params as { key: string }).key; + throw new Error("initial dispatch failed"); + }); + const deleteSession = vi + .spyOn(sessionsHandlers, "sessions.delete") + .mockImplementation(async ({ params, respond }) => { + expect(params).toMatchObject({ key: sessionKey, agentId: "main" }); + respond(true, { ok: true, deleted: true }, undefined); + }); + + const accepted = await call("taskSuggestions.accept", { taskId }); + const listed = await call("taskSuggestions.list", {}); + + expect(accepted.response?.[0]).toBe(false); + expect(accepted.response?.[2]).toMatchObject({ message: "initial dispatch failed" }); + expect(deleteSession).toHaveBeenCalledTimes(1); + expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] }); + }); + + it("expires a suggestion when partial session rollback cannot finish", async () => { + const created = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + vi.spyOn(sessionsHandlers, "sessions.create").mockRejectedValue( + new Error("initial dispatch failed"), + ); + vi.spyOn(sessionsHandlers, "sessions.delete").mockImplementation(async ({ respond }) => { + respond(false, undefined, { code: "UNAVAILABLE", message: "still active" }); + }); + vi.spyOn(managedWorktrees, "findLiveByOwner").mockReturnValue({ id: "wt_partial" } as never); + vi.spyOn(managedWorktrees, "remove").mockRejectedValue(new Error("still active")); + + const accepted = await call("taskSuggestions.accept", { taskId }); + const listed = await call("taskSuggestions.list", {}); + + expect(accepted.response?.[0]).toBe(false); + expect(accepted.response?.[2]?.message).toContain("failed to roll back"); + expect(accepted.broadcast).toHaveBeenCalledWith( + "task.suggestion", + { action: "resolved", taskId, resolution: "expired" }, + { dropIfSlow: true }, + ); + expect(listed.response?.[1]).toEqual({ suggestions: [] }); + }); + + it("rejects a relative cwd before recording or broadcasting", async () => { + const result = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "relative/repo", + sessionKey: "agent:main:main", + }); + + expect(result.response?.[0]).toBe(false); + expect(result.response?.[2]).toMatchObject({ message: "task suggestion cwd must be absolute" }); + expect(result.broadcast).not.toHaveBeenCalled(); + }); + + it("rejects an agent that conflicts with the source session", async () => { + const result = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "work", + }); + + expect(result.response?.[0]).toBe(false); + expect(result.response?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: "task suggestion agentId must match its source session", + }); + expect(result.broadcast).not.toHaveBeenCalled(); + }); + + it("rejects retained fields beyond their protocol limits", async () => { + const result = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "x".repeat(32_769), + tldr: "The edge case is untested.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + + expect(result.response?.[0]).toBe(false); + expect(result.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" }); + expect(result.broadcast).not.toHaveBeenCalled(); + }); + + it("keeps the complete list below the retained payload budget", async () => { + const taskIds: string[] = []; + for (let index = 0; index < 70; index += 1) { + const created = await call("taskSuggestions.create", { + title: `Follow up ${index}`, + prompt: `${index}: ${"x".repeat(32_760)}`, + tldr: "The follow-up remains useful.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + taskIds.push((requirePayload(created) as { taskId: string }).taskId); + } + + const listed = await call("taskSuggestions.list", {}); + const payload = requirePayload(listed) as { suggestions: Array<{ id: string }> }; + expect(Buffer.byteLength(JSON.stringify(payload.suggestions))).toBeLessThanOrEqual( + MAX_TASK_SUGGESTION_RETAINED_BYTES, + ); + expect(payload.suggestions.length).toBeLessThan(70); + expect(payload.suggestions.some((suggestion) => suggestion.id === taskIds[0])).toBe(false); + }); + + it("broadcasts when the bounded registry expires a pending suggestion", async () => { + const taskIds: string[] = []; + for (let index = 0; index < 100; index += 1) { + const created = await call("taskSuggestions.create", { + title: `Follow up ${index}`, + prompt: `Complete follow-up task ${index}.`, + tldr: `Follow-up task ${index} remains useful.`, + cwd: "/repo", + sessionKey: "agent:main:main", + }); + taskIds.push((requirePayload(created) as { taskId: string }).taskId); + } + + const replacement = await call("taskSuggestions.create", { + title: "Latest follow up", + prompt: "Complete the latest follow-up task.", + tldr: "The latest follow-up remains useful.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + + expect(replacement.response?.[0]).toBe(true); + expect(replacement.broadcast).toHaveBeenNthCalledWith( + 1, + "task.suggestion", + { action: "resolved", taskId: taskIds[0], resolution: "expired" }, + { dropIfSlow: true }, + ); + const listed = await call("taskSuggestions.list", {}); + expect((requirePayload(listed) as { suggestions: unknown[] }).suggestions).toHaveLength(100); + }); + + it("rejects a new suggestion when every bounded registry entry is accepting", async () => { + for (let index = 0; index < 100; index += 1) { + const created = await call("taskSuggestions.create", { + title: `Follow up ${index}`, + prompt: `Complete follow-up task ${index}.`, + tldr: `Follow-up task ${index} remains useful.`, + cwd: "/repo", + sessionKey: "agent:main:main", + }); + const taskId = (requirePayload(created) as { taskId: string }).taskId; + expect(beginTaskSuggestionAcceptance(taskId).status).toBe("claimed"); + } + + const rejected = await call("taskSuggestions.create", { + title: "One too many", + prompt: "Complete one more follow-up task.", + tldr: "This follow-up can wait until capacity returns.", + cwd: "/repo", + sessionKey: "agent:main:main", + }); + + expect(rejected.response?.[0]).toBe(false); + expect(rejected.response?.[2]).toMatchObject({ + code: "UNAVAILABLE", + message: "task suggestion registry is busy", + retryable: true, + }); + expect(rejected.broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/task-suggestions.ts b/src/gateway/server-methods/task-suggestions.ts new file mode 100644 index 00000000000..926140b1be0 --- /dev/null +++ b/src/gateway/server-methods/task-suggestions.ts @@ -0,0 +1,373 @@ +// Gateway methods for ephemeral model-proposed follow-up tasks. +import path from "node:path"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + type TaskSuggestion, + type TaskSuggestionsAcceptResult, + validateTaskSuggestionsAcceptParams, + validateTaskSuggestionsCreateParams, + validateTaskSuggestionsDismissParams, + validateTaskSuggestionsListParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { buildDashboardSessionKey } from "../session-create-service.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { + abandonTaskSuggestionAcceptance, + beginTaskSuggestionAcceptance, + cancelTaskSuggestionAcceptance, + completeTaskSuggestionAcceptance, + createTaskSuggestion, + dismissTaskSuggestion, + listTaskSuggestions, +} from "../task-suggestion-registry.js"; +import { sessionsHandlers } from "./sessions.js"; +import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js"; + +function invalidParams(method: string, errors: Parameters[0]) { + return errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid ${method} params: ${formatValidationErrors(errors)}`, + ); +} + +type TaskSuggestionAcceptanceResult = + | { ok: true; result: TaskSuggestionsAcceptResult } + | { ok: false; error: NonNullable[2]> }; + +const activeAcceptances = new Map>(); + +async function rollbackSuggestedTaskSession(params: { + key: string; + agentId?: string; + options: GatewayRequestHandlerOptions; +}): Promise { + let deletionConfirmed = false; + try { + await sessionsHandlers["sessions.delete"]?.({ + ...params.options, + params: { + key: params.key, + ...(params.agentId ? { agentId: params.agentId } : {}), + deleteTranscript: true, + emitLifecycleHooks: false, + }, + respond: (ok, payload) => { + deletionConfirmed = Boolean( + ok && + payload && + typeof payload === "object" && + typeof (payload as { deleted?: unknown }).deleted === "boolean", + ); + }, + }); + } catch { + // The state probes below determine whether the preallocated session key + // and its worktree were fully removed despite a handler-level failure. + } + try { + if (!deletionConfirmed && loadSessionEntry(params.key, { agentId: params.agentId }).entry) { + return false; + } + } catch { + return false; + } + const worktree = managedWorktrees.findLiveByOwner("session", params.key); + if (worktree) { + try { + await managedWorktrees.remove({ + id: worktree.id, + reason: "suggested-task-seed-failed", + force: true, + }); + } catch { + return false; + } + } + return managedWorktrees.findLiveByOwner("session", params.key) === undefined; +} + +async function failSuggestedTaskSession(params: { + taskId: string; + sessionKey: string; + agentId: string; + options: GatewayRequestHandlerOptions; + error: NonNullable[2]>; +}): Promise { + const rolledBack = await rollbackSuggestedTaskSession({ + key: params.sessionKey, + agentId: params.agentId, + options: params.options, + }); + if (rolledBack) { + const restored = cancelTaskSuggestionAcceptance(params.taskId); + if (restored) { + params.options.context.broadcast( + "task.suggestion", + { action: "created", suggestion: restored }, + { dropIfSlow: true }, + ); + } + return { ok: false, error: params.error }; + } + if (abandonTaskSuggestionAcceptance(params.taskId)) { + params.options.context.broadcast( + "task.suggestion", + { action: "resolved", taskId: params.taskId, resolution: "expired" }, + { dropIfSlow: true }, + ); + } + return { + ok: false, + error: errorShape( + ErrorCodes.UNAVAILABLE, + `${params.error.message}; failed to roll back the partial suggested task session`, + ), + }; +} + +async function createSuggestedTaskSession(params: { + taskId: string; + suggestion: TaskSuggestion; + options: GatewayRequestHandlerOptions; +}): Promise { + let sessionResponse: Parameters | undefined; + const agentId = normalizeAgentId( + params.suggestion.agentId ?? + parseAgentSessionKey(params.suggestion.sessionKey)?.agentId ?? + resolveDefaultAgentId(params.options.context.getRuntimeConfig()), + ); + const sessionKey = buildDashboardSessionKey(agentId); + try { + await sessionsHandlers["sessions.create"]?.({ + ...params.options, + params: { + key: sessionKey, + agentId, + parentSessionKey: params.suggestion.sessionKey, + label: params.suggestion.title, + task: params.suggestion.prompt, + worktree: true, + cwd: params.suggestion.cwd, + }, + respond: (...args) => { + sessionResponse = args; + }, + }); + } catch (error) { + return await failSuggestedTaskSession({ + taskId: params.taskId, + sessionKey, + agentId, + options: params.options, + error: errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)), + }); + } + if (!sessionResponse) { + return await failSuggestedTaskSession({ + taskId: params.taskId, + sessionKey, + agentId, + options: params.options, + error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"), + }); + } + const [ok, payload, error] = sessionResponse; + if (!ok) { + return await failSuggestedTaskSession({ + taskId: params.taskId, + sessionKey, + agentId, + options: params.options, + error: error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"), + }); + } + const key = + payload && typeof payload === "object" && typeof (payload as { key?: unknown }).key === "string" + ? (payload as { key: string }).key + : undefined; + if (!key) { + return await failSuggestedTaskSession({ + taskId: params.taskId, + sessionKey, + agentId, + options: params.options, + error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"), + }); + } + const result = payload as { runError?: unknown; runStarted?: unknown }; + if (result.runStarted !== true) { + const runMessage = + result.runError && + typeof result.runError === "object" && + typeof (result.runError as { message?: unknown }).message === "string" + ? (result.runError as { message: string }).message + : "initial task did not start"; + return await failSuggestedTaskSession({ + taskId: params.taskId, + sessionKey: key, + agentId, + options: params.options, + error: errorShape(ErrorCodes.UNAVAILABLE, runMessage), + }); + } + completeTaskSuggestionAcceptance(params.taskId, key); + params.options.context.broadcast( + "task.suggestion", + { action: "resolved", taskId: params.taskId, resolution: "accepted" }, + { dropIfSlow: true }, + ); + return { ok: true, result: { taskId: params.taskId, key } }; +} + +export const taskSuggestionsHandlers: GatewayRequestHandlers = { + "taskSuggestions.list": ({ params, respond }) => { + if (!validateTaskSuggestionsListParams(params)) { + respond( + false, + undefined, + invalidParams("taskSuggestions.list", validateTaskSuggestionsListParams.errors), + ); + return; + } + respond(true, { suggestions: listTaskSuggestions(params) }, undefined); + }, + "taskSuggestions.create": ({ params, respond, context }) => { + if (!validateTaskSuggestionsCreateParams(params)) { + respond( + false, + undefined, + invalidParams("taskSuggestions.create", validateTaskSuggestionsCreateParams.errors), + ); + return; + } + if (!path.isAbsolute(params.cwd)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "task suggestion cwd must be absolute"), + ); + return; + } + const sessionAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; + const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + if ( + requestedAgentId && + sessionAgentId && + requestedAgentId !== normalizeAgentId(sessionAgentId) + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "task suggestion agentId must match its source session", + ), + ); + return; + } + const agentId = normalizeAgentId( + requestedAgentId ?? sessionAgentId ?? resolveDefaultAgentId(context.getRuntimeConfig()), + ); + const created = createTaskSuggestion({ ...params, agentId }); + if (created.status === "full") { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "task suggestion registry is busy", { + retryable: true, + }), + ); + return; + } + const { suggestion } = created; + // The registry is ephemeral; live events keep open Control UI tabs in sync + // without turning suggestions into durable task state. + for (const taskId of created.evictedPendingTaskIds) { + context.broadcast( + "task.suggestion", + { action: "resolved", taskId, resolution: "expired" }, + { dropIfSlow: true }, + ); + } + context.broadcast("task.suggestion", { action: "created", suggestion }, { dropIfSlow: true }); + respond(true, { taskId: suggestion.id, suggestion }, undefined); + }, + "taskSuggestions.accept": async (options) => { + const { params, respond } = options; + if (!validateTaskSuggestionsAcceptParams(params)) { + respond( + false, + undefined, + invalidParams("taskSuggestions.accept", validateTaskSuggestionsAcceptParams.errors), + ); + return; + } + const active = activeAcceptances.get(params.taskId); + if (active) { + const outcome = await active; + respond( + outcome.ok, + outcome.ok ? outcome.result : undefined, + outcome.ok ? undefined : outcome.error, + ); + return; + } + const acceptance = beginTaskSuggestionAcceptance(params.taskId); + if (acceptance.status === "accepted") { + respond(true, { taskId: params.taskId, key: acceptance.sessionKey }, undefined); + return; + } + if (acceptance.status !== "claimed") { + respond( + false, + undefined, + errorShape( + acceptance.status === "accepting" ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, + `task suggestion cannot be accepted: ${acceptance.status}`, + ), + ); + return; + } + const pending = createSuggestedTaskSession({ + taskId: params.taskId, + suggestion: acceptance.suggestion, + options, + }); + activeAcceptances.set(params.taskId, pending); + try { + const outcome = await pending; + respond( + outcome.ok, + outcome.ok ? outcome.result : undefined, + outcome.ok ? undefined : outcome.error, + ); + } finally { + activeAcceptances.delete(params.taskId); + } + }, + "taskSuggestions.dismiss": ({ params, respond, context }) => { + if (!validateTaskSuggestionsDismissParams(params)) { + respond( + false, + undefined, + invalidParams("taskSuggestions.dismiss", validateTaskSuggestionsDismissParams.errors), + ); + return; + } + const dismissed = dismissTaskSuggestion(params.taskId); + if (dismissed) { + context.broadcast( + "task.suggestion", + { action: "resolved", taskId: params.taskId, resolution: "dismissed" }, + { dropIfSlow: true }, + ); + } + respond(true, { taskId: params.taskId, dismissed }, undefined); + }, +}; diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index faa2abe464b..5773e068438 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -521,7 +521,7 @@ test("sessions.compact without maxLines runs embedded manual compaction for chec throw new Error("expected embedded compaction session file"); } expect(path.basename(compactionCall.sessionFile)).toBe("sess-main.jsonl"); - expect(compactionCall.workspaceDir).toBe(path.join(os.tmpdir(), "openclaw-gateway-test")); + expect(compactionCall.workspaceDir).toBe("/tmp/task-repo"); expect(compactionCall.cwd).toBe("/tmp/task-repo"); expect(callConfig.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-6"); expect(callConfig.agents?.defaults?.workspace).toBe( diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 945d66cd990..2251a7edd66 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -122,7 +122,10 @@ test("sessions.create provisions and reuses a session worktree for later runs", }); expect(run.ok).toBe(true); await vi.waitFor(() => expect(agentCommand).toHaveBeenCalled()); - expect(agentCommand.mock.calls.at(-1)?.[0]).toMatchObject({ cwd: worktree?.path }); + expect(agentCommand.mock.calls.at(-1)?.[0]).toMatchObject({ + cwd: worktree?.path, + workspaceDir: worktree?.path, + }); ws.close(); } finally { if (worktreeId) { @@ -139,6 +142,83 @@ test("sessions.create provisions and reuses a session worktree for later runs", } }); +test("sessions.create provisions a worktree from an admin-selected cwd", async () => { + const configuredRoot = await fs.mkdtemp( + path.join(await fs.realpath(os.tmpdir()), "openclaw-configured-workspace-"), + ); + const selectedRoot = await fs.mkdtemp( + path.join(await fs.realpath(os.tmpdir()), "openclaw-selected-workspace-"), + ); + const configuredWorkspace = await initializeGitWorkspace(configuredRoot); + const selectedWorkspace = await initializeGitWorkspace(selectedRoot); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = path.join(configuredRoot, "state"); + closeOpenClawStateDatabaseForTest(); + testState.agentConfig = { workspace: configuredWorkspace }; + await createSessionStoreDir(); + let worktreeId: string | undefined; + try { + const created = await directSessionReq<{ + key: string; + entry: { spawnedCwd?: string }; + worktree: { id: string; path: string }; + }>( + "sessions.create", + { agentId: "main", worktree: true, cwd: selectedWorkspace }, + { client: { connect: { scopes: ["operator.admin"] } } as never }, + ); + + expect(created.ok).toBe(true); + const worktree = created.payload?.worktree; + worktreeId = worktree?.id; + expect(created.payload?.entry.spawnedCwd).toBe(worktree?.path); + expect( + findLiveRegistryWorktreeByOwner(process.env, "session", created.payload?.key ?? ""), + ).toMatchObject({ + id: worktree?.id, + repoRoot: selectedWorkspace, + }); + + const mismatched = await directSessionReq( + "sessions.create", + { + key: created.payload?.key, + agentId: "main", + worktree: true, + cwd: configuredWorkspace, + }, + { client: { connect: { scopes: ["operator.admin"] } } as never }, + ); + expect(mismatched).toMatchObject({ + ok: false, + error: { message: "session worktree belongs to a different repository" }, + }); + } finally { + if (worktreeId) { + await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true }); + } + closeOpenClawStateDatabaseForTest(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + testState.agentConfig = undefined; + await fs.rm(configuredRoot, { recursive: true, force: true }); + await fs.rm(selectedRoot, { recursive: true, force: true }); + } +}); + +test("sessions.create rejects cwd without a managed worktree", async () => { + const created = await directSessionReq("sessions.create", { cwd: "/tmp/repo" }); + + expect(created.ok).toBe(false); + expect(created.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "sessions.create cwd requires worktree=true", + }); +}); + test("sessions.create skips the worktree setup script for non-admin callers", async () => { const root = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-worktree-setup-scope-"), @@ -183,12 +263,14 @@ test("sessions.create skips the worktree setup script for non-admin callers", as } }); -test("sessions.create accepts a workspace configured as a repo subdirectory", async () => { +test("sessions.create preserves a linked-worktree subdirectory", async () => { const root = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-subdir-session-worktree-"), ); const repoRoot = await initializeGitWorkspace(root); - const workspace = path.join(repoRoot, "packages", "app"); + const linkedRoot = path.join(root, "linked"); + await execFileAsync("git", ["-C", repoRoot, "worktree", "add", "-b", "linked", linkedRoot]); + const workspace = path.join(linkedRoot, "packages", "app"); await fs.mkdir(workspace, { recursive: true }); const previousStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = path.join(root, "state"); diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 0c20bdd99bb..f944d9aaae7 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -985,6 +985,42 @@ test("sessions.compact passes the selected global agent into embedded compaction await resetConfiguredGlobalAgentSessionStore(globalStores); }); +test("sessions.compact mounts a dashboard managed worktree as its workspace", async () => { + const { dir } = await createSessionStoreDir(); + const sessionFile = path.join(dir, "sess-suggested.jsonl"); + await fs.writeFile( + sessionFile, + createLinearSessionTranscript("sess-suggested", ["one", "two"]), + "utf-8", + ); + await writeSessionStore({ + entries: { + "dashboard:suggested": sessionStoreEntry("sess-suggested", { + sessionFile, + spawnedCwd: "/tmp/suggested-worktree", + }), + }, + }); + const { getRuntimeConfig } = await getGatewayConfigModule(); + + const { responsePayload } = await invokeSessionsCompact({ + getRuntimeConfig, + params: { key: "agent:main:dashboard:suggested" }, + subscribedConnIds: new Set(), + }); + + expect(embeddedRunMock.compactEmbeddedAgentSession).toHaveBeenCalledTimes(1); + expectFields(responsePayload, { + ok: true, + key: "agent:main:dashboard:suggested", + compacted: true, + }); + expect(embeddedRunMock.compactEmbeddedAgentSession.mock.calls[0]?.[0]).toMatchObject({ + workspaceDir: "/tmp/suggested-worktree", + cwd: "/tmp/suggested-worktree", + }); +}); + test("sessions.changed mutation events include subagent ownership metadata", async () => { await createSessionStoreDir(); await writeSessionStore({ diff --git a/src/gateway/task-suggestion-registry.ts b/src/gateway/task-suggestion-registry.ts new file mode 100644 index 00000000000..d8e07ef5223 --- /dev/null +++ b/src/gateway/task-suggestion-registry.ts @@ -0,0 +1,157 @@ +// Process-local registry for model-proposed follow-up tasks. +import { randomUUID } from "node:crypto"; +import type { + TaskSuggestion, + TaskSuggestionsCreateParams, + TaskSuggestionsListParams, +} from "../../packages/gateway-protocol/src/index.js"; + +const MAX_TASK_SUGGESTIONS = 100; +export const MAX_TASK_SUGGESTION_RETAINED_BYTES = 2 * 1024 * 1024; +type TaskSuggestionRecord = + | { status: "pending" | "accepting" | "dismissed"; suggestion: TaskSuggestion } + | { status: "accepted"; suggestion: TaskSuggestion; sessionKey: string }; + +const suggestions = new Map(); +let retainedSuggestionBytes = 0; + +function retainedBytesForSuggestion(suggestion: TaskSuggestion): number { + // Account for one array delimiter per record. Keeping one extra byte free + // covers the surrounding brackets in the list response. + return Buffer.byteLength(JSON.stringify(suggestion)) + 1; +} + +export type CreateTaskSuggestionResult = + | { status: "created"; suggestion: TaskSuggestion; evictedPendingTaskIds: string[] } + | { status: "full" }; + +function evictTaskSuggestion(): string | null | undefined { + for (const [taskId, record] of suggestions) { + if (record.status === "accepted" || record.status === "dismissed") { + retainedSuggestionBytes -= retainedBytesForSuggestion(record.suggestion); + suggestions.delete(taskId); + return null; + } + } + for (const [taskId, record] of suggestions) { + if (record.status === "pending") { + retainedSuggestionBytes -= retainedBytesForSuggestion(record.suggestion); + suggestions.delete(taskId); + return taskId; + } + } + return undefined; +} + +/** Records one suggestion without starting work. IDs intentionally vanish on restart. */ +export function createTaskSuggestion( + params: TaskSuggestionsCreateParams, +): CreateTaskSuggestionResult { + const suggestion: TaskSuggestion = { + id: `task_${randomUUID()}`, + title: params.title, + prompt: params.prompt, + tldr: params.tldr, + cwd: params.cwd, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + createdAt: Date.now(), + }; + const suggestionBytes = retainedBytesForSuggestion(suggestion); + const evictedPendingTaskIds: string[] = []; + while ( + suggestions.size >= MAX_TASK_SUGGESTIONS || + retainedSuggestionBytes + suggestionBytes + 1 > MAX_TASK_SUGGESTION_RETAINED_BYTES + ) { + const evictedTaskId = evictTaskSuggestion(); + if (evictedTaskId === undefined) { + // All retained tasks are in-flight acceptances. Reject new work instead + // of losing either its UI card or an acceptance's idempotency result. + return { status: "full" }; + } + if (evictedTaskId) { + evictedPendingTaskIds.push(evictedTaskId); + } + } + suggestions.set(suggestion.id, { status: "pending", suggestion }); + retainedSuggestionBytes += suggestionBytes; + return { status: "created", suggestion, evictedPendingTaskIds }; +} + +/** Lists newest suggestions first, optionally scoped to their source chat. */ +export function listTaskSuggestions(params: TaskSuggestionsListParams): TaskSuggestion[] { + return [...suggestions.values()] + .filter((record) => record.status === "pending") + .map((record) => record.suggestion) + .filter( + (suggestion) => + (!params.sessionKey || suggestion.sessionKey === params.sessionKey) && + (!params.agentId || suggestion.agentId === params.agentId), + ) + .toReversed(); +} + +export type TaskSuggestionAcceptance = + | { status: "claimed"; suggestion: TaskSuggestion } + | { status: "accepted"; sessionKey: string } + | { status: "accepting" | "dismissed" | "missing" }; + +/** Claims one suggestion before any privileged worktree/session side effects begin. */ +export function beginTaskSuggestionAcceptance(taskId: string): TaskSuggestionAcceptance { + const record = suggestions.get(taskId); + if (!record) { + return { status: "missing" }; + } + if (record.status === "accepted") { + return { status: "accepted", sessionKey: record.sessionKey }; + } + if (record.status !== "pending") { + return { status: record.status }; + } + suggestions.set(taskId, { status: "accepting", suggestion: record.suggestion }); + return { status: "claimed", suggestion: record.suggestion }; +} + +/** Restores a claim when session creation fails before an acceptance result exists. */ +export function cancelTaskSuggestionAcceptance(taskId: string): TaskSuggestion | undefined { + const record = suggestions.get(taskId); + if (record?.status === "accepting") { + suggestions.set(taskId, { status: "pending", suggestion: record.suggestion }); + return record.suggestion; + } + return undefined; +} + +/** Retires a claimed suggestion when partial side effects cannot be rolled back safely. */ +export function abandonTaskSuggestionAcceptance(taskId: string): boolean { + const record = suggestions.get(taskId); + if (record?.status !== "accepting") { + return false; + } + suggestions.set(taskId, { status: "dismissed", suggestion: record.suggestion }); + return true; +} + +/** Retains the created session key so retries return the same accepted task. */ +export function completeTaskSuggestionAcceptance(taskId: string, sessionKey: string): void { + const record = suggestions.get(taskId); + if (record?.status === "accepting") { + suggestions.set(taskId, { status: "accepted", suggestion: record.suggestion, sessionKey }); + } +} + +/** Dismisses only a pending suggestion; accepted or in-flight tasks stay immutable. */ +export function dismissTaskSuggestion(taskId: string): boolean { + const record = suggestions.get(taskId); + if (record?.status !== "pending") { + return false; + } + suggestions.set(taskId, { status: "dismissed", suggestion: record.suggestion }); + return true; +} + +/** Test-only reset for the intentionally process-local registry. */ +export function resetTaskSuggestionsForTest(): void { + suggestions.clear(); + retainedSuggestionBytes = 0; +} diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index d6db0841122..a0ce4808177 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -1217,6 +1217,159 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("starts a model-suggested follow-up in a fresh worktree session", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const suggestion = { + id: "task_123", + title: "Remove stale adapter", + prompt: "Delete the stale adapter in src/example.ts and update tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/projects/example", + sessionKey: "main", + agentId: "main", + createdAt: Date.now(), + }; + const gateway = await installMockGateway(page, { + deferredMethods: ["taskSuggestions.list"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "taskSuggestions.list", + "taskSuggestions.accept", + ], + methodResponses: { + "taskSuggestions.list": { suggestions: [suggestion] }, + "taskSuggestions.accept": { + taskId: "task_123", + key: "agent:main:dashboard:suggested", + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("taskSuggestions.list"); + await gateway.emitGatewayEvent("task.suggestion", { + action: "created", + suggestion, + }); + await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); + + const startButton = page.getByRole("button", { name: "Start in worktree" }); + await startButton.waitFor({ state: "visible", timeout: 10_000 }); + await page + .getByText("/projects/example", { exact: true }) + .waitFor({ state: "visible", timeout: 10_000 }); + await page + .getByText("Delete the stale adapter in src/example.ts and update tests.", { + exact: true, + }) + .waitFor({ state: "visible", timeout: 10_000 }); + await startButton.click(); + + const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept"); + expect(acceptRequest.params).toEqual({ taskId: "task_123" }); + } finally { + await closeBrowserContext(context); + } + }); + + it("clears model-suggested follow-ups while switching sessions", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + methodResponses: { + "sessions.list": chatSessionListResponse(), + "taskSuggestions.list": { + suggestions: [ + { + id: "task_session_a", + title: "Follow up from session A", + prompt: "Complete the follow-up discovered in session A.", + tldr: "This suggestion belongs only to session A.", + cwd: "/projects/example", + sessionKey: "agent:main:session-a", + agentId: "main", + createdAt: Date.now(), + }, + ], + }, + }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${server.baseUrl}chat`); + const startButton = page.getByRole("button", { name: "Start in worktree" }); + await startButton.waitFor({ state: "visible", timeout: 10_000 }); + await gateway.deferNext("taskSuggestions.list"); + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ) + .click(); + await waitForRequests(gateway, "taskSuggestions.list", 2); + + await expect.poll(() => startButton.count()).toBe(0); + await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); + } finally { + await closeBrowserContext(context); + } + }); + + it("keeps the composer visible when follow-up suggestions overflow", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 720, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + methodResponses: { + "taskSuggestions.list": { + suggestions: Array.from({ length: 12 }, (_, index) => ({ + id: `task_overflow_${index}`, + title: `Follow-up ${index}`, + prompt: "Inspect the related implementation and tests. ".repeat(12), + tldr: "This follow-up remains useful but must not hide the composer.", + cwd: "/projects/example", + sessionKey: "main", + agentId: "main", + createdAt: Date.now() + index, + })), + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + const tray = page.locator(".task-suggestions"); + await tray.waitFor({ state: "visible", timeout: 10_000 }); + expect(await tray.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe( + true, + ); + + const composer = page.locator(".agent-chat__composer-shell"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + const box = await composer.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.y ?? 720) + (box?.height ?? 0)).toBeLessThanOrEqual(720); + } finally { + await closeBrowserContext(context); + } + }); + it("sends the first chat turn while agents startup loading is still pending", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index f5a42d7be73..742be84f21d 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.329Z", + "generatedAt": "2026-07-09T02:23:54.830Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index db3f1ff1633..61663d05a98 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.592Z", + "generatedAt": "2026-07-09T02:23:54.190Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 850fdd4f59c..30c399a7a86 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.716Z", + "generatedAt": "2026-07-09T02:23:54.294Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index b751ae357e6..168254320be 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:19.480Z", + "generatedAt": "2026-07-09T02:23:55.791Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 5e7ea1967d4..3ca4c5f5194 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.082Z", + "generatedAt": "2026-07-09T02:23:54.618Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index 3b76bbb3249..9f1c613808a 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.205Z", + "generatedAt": "2026-07-09T02:23:54.719Z", "locale": "hi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 5ea072f3683..95c1599d331 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.814Z", + "generatedAt": "2026-07-09T02:23:55.260Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index a36589116a8..1fac3ca8a92 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.447Z", + "generatedAt": "2026-07-09T02:23:54.937Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index e2ac72a318c..7545989801a 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.838Z", + "generatedAt": "2026-07-09T02:23:54.400Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index fd7d35196d3..e8f7da16b7f 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.959Z", + "generatedAt": "2026-07-09T02:23:54.509Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index 537610451b7..fed941ae95c 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:19.296Z", + "generatedAt": "2026-07-09T02:23:55.688Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 8cc0653712e..0bae1cc1f11 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.930Z", + "generatedAt": "2026-07-09T02:23:55.361Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 7ddcf95da29..7dec5e5a3d8 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.472Z", + "generatedAt": "2026-07-09T02:23:54.091Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index bd354ffb1c4..541988bbd3d 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:19.608Z", + "generatedAt": "2026-07-09T02:23:55.899Z", "locale": "ru", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index dc3393d1bc7..9e53469b018 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:19.058Z", + "generatedAt": "2026-07-09T02:23:55.469Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 469f4015e35..dbf8cf5b49e 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.569Z", + "generatedAt": "2026-07-09T02:23:55.051Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 155c74af7ee..fe7d1f7b508 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:18.689Z", + "generatedAt": "2026-07-09T02:23:55.154Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index ddf3016a22b..9c9d50671ac 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:19.179Z", + "generatedAt": "2026-07-09T02:23:55.576Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 654a839a2fd..3e5f3f57f43 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.217Z", + "generatedAt": "2026-07-09T02:23:53.872Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index fe6f8a1b3ea..d883c272a2a 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-08T13:50:17.351Z", + "generatedAt": "2026-07-09T02:23:53.982Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9", - "totalKeys": 1669, - "translatedKeys": 1669, + "sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c", + "totalKeys": 1676, + "translatedKeys": 1676, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 2c8549f0714..c8bf6d43c41 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -1539,6 +1539,15 @@ export const ar: TranslationMap = { chat: { disconnected: "تم قطع الاتصال بـ Gateway.", archivedSessionDisabled: "استعِد هذه الجلسة لإرسال الرسائل.", + taskSuggestions: { + eyebrow: "متابعة مقترحة", + start: "البدء في شجرة عمل", + starting: "جارٍ البدء…", + dismiss: "تجاهل {title}", + project: "المشروع", + instructions: "التعليمات", + adminRequired: "يلزم وصول المسؤول لإنشاء شجرة عمل من هذا المشروع.", + }, refreshTitle: "تحديث بيانات الدردشة", settings: "إعدادات الدردشة", usageRemaining: "الاستخدام المتبقي", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 39b15368898..3ae4a356796 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -1569,6 +1569,16 @@ export const de: TranslationMap = { chat: { disconnected: "Verbindung zum Gateway getrennt.", archivedSessionDisabled: "Stellen Sie diese Sitzung wieder her, um Nachrichten zu senden.", + taskSuggestions: { + eyebrow: "Vorgeschlagene Folgeaufgabe", + start: "In Worktree starten", + starting: "Wird gestartet…", + dismiss: "{title} verwerfen", + project: "Projekt", + instructions: "Anweisungen", + adminRequired: + "Administratorzugriff ist erforderlich, um einen Worktree für dieses Projekt zu erstellen.", + }, refreshTitle: "Chat-Daten aktualisieren", settings: "Chat-Einstellungen", usageRemaining: "Verbleibende Nutzung", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2f1ed1db666..f386eafef55 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1545,6 +1545,15 @@ export const en: TranslationMap = { chat: { disconnected: "Disconnected from gateway.", archivedSessionDisabled: "Restore this session to send messages.", + taskSuggestions: { + eyebrow: "Suggested follow-up", + start: "Start in worktree", + starting: "Starting…", + dismiss: "Dismiss {title}", + project: "Project", + instructions: "Instructions", + adminRequired: "Administrator access is required to create a worktree from this project.", + }, refreshTitle: "Refresh chat data", settings: "Chat settings", usageRemaining: "Usage Remaining", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 24d6948c469..4924ea6be51 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -1566,6 +1566,16 @@ export const es: TranslationMap = { chat: { disconnected: "Desconectado de la puerta de enlace.", archivedSessionDisabled: "Restaura esta sesión para enviar mensajes.", + taskSuggestions: { + eyebrow: "Tarea de seguimiento sugerida", + start: "Iniciar en un worktree", + starting: "Iniciando…", + dismiss: "Descartar {title}", + project: "Proyecto", + instructions: "Instrucciones", + adminRequired: + "Se requiere acceso de administrador para crear un worktree para este proyecto.", + }, refreshTitle: "Actualizar datos del chat", settings: "Configuración del chat", usageRemaining: "Uso restante", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index ceb9a259230..5cc8435e243 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -1557,6 +1557,15 @@ export const fa: TranslationMap = { chat: { disconnected: "اتصال از Gateway قطع شد.", archivedSessionDisabled: "برای ارسال پیام، این نشست را بازیابی کنید.", + taskSuggestions: { + eyebrow: "کار پیگیری پیشنهادی", + start: "شروع در درخت کاری", + starting: "در حال شروع…", + dismiss: "رد کردن {title}", + project: "پروژه", + instructions: "دستورالعمل‌ها", + adminRequired: "برای ایجاد درخت کاری از این پروژه، دسترسی مدیر لازم است.", + }, refreshTitle: "تازه‌سازی داده‌های چت", settings: "تنظیمات چت", usageRemaining: "استفاده باقی‌مانده", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 32297744649..d82441db9c7 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -1576,6 +1576,16 @@ export const fr: TranslationMap = { chat: { disconnected: "Déconnecté du Gateway.", archivedSessionDisabled: "Restaurez cette session pour envoyer des messages.", + taskSuggestions: { + eyebrow: "Suivi suggéré", + start: "Démarrer dans un worktree", + starting: "Démarrage…", + dismiss: "Ignorer {title}", + project: "Projet", + instructions: "Instructions", + adminRequired: + "Un accès administrateur est requis pour créer un worktree à partir de ce projet.", + }, refreshTitle: "Actualiser les données du chat", settings: "Paramètres de chat", usageRemaining: "Utilisation restante", diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index c1921ad66f1..1f9824ed309 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -1541,6 +1541,15 @@ export const hi: TranslationMap = { chat: { disconnected: "Gateway से डिस्कनेक्ट हो गया।", archivedSessionDisabled: "संदेश भेजने के लिए इस सत्र को बहाल करें।", + taskSuggestions: { + eyebrow: "सुझाया गया अगला कार्य", + start: "वर्कट्री में शुरू करें", + starting: "शुरू हो रहा है…", + dismiss: "{title} को खारिज करें", + project: "प्रोजेक्ट", + instructions: "निर्देश", + adminRequired: "इस प्रोजेक्ट से वर्कट्री बनाने के लिए एडमिनिस्ट्रेटर एक्सेस आवश्यक है।", + }, refreshTitle: "चैट डेटा रीफ़्रेश करें", settings: "चैट सेटिंग्स", usageRemaining: "शेष उपयोग", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 93ad13dbb31..b259bf46ce2 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -1558,6 +1558,15 @@ export const id: TranslationMap = { chat: { disconnected: "Terputus dari gateway.", archivedSessionDisabled: "Pulihkan sesi ini untuk mengirim pesan.", + taskSuggestions: { + eyebrow: "Tindak lanjut yang disarankan", + start: "Mulai di worktree", + starting: "Memulai…", + dismiss: "Tutup {title}", + project: "Proyek", + instructions: "Petunjuk", + adminRequired: "Akses administrator diperlukan untuk membuat worktree dari proyek ini.", + }, refreshTitle: "Refresh data chat", settings: "Pengaturan chat", usageRemaining: "Sisa penggunaan", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index facfda266dd..756d39e9175 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -1568,6 +1568,16 @@ export const it: TranslationMap = { chat: { disconnected: "Disconnesso dal gateway.", archivedSessionDisabled: "Ripristina questa sessione per inviare messaggi.", + taskSuggestions: { + eyebrow: "Attività successiva suggerita", + start: "Avvia in un worktree", + starting: "Avvio…", + dismiss: "Ignora {title}", + project: "Progetto", + instructions: "Istruzioni", + adminRequired: + "È necessario l'accesso amministratore per creare un worktree da questo progetto.", + }, refreshTitle: "Aggiorna dati chat", settings: "Impostazioni chat", usageRemaining: "Utilizzo rimanente", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index a2114930a9b..aa04ae3c61e 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -1564,6 +1564,15 @@ export const ja_JP: TranslationMap = { chat: { disconnected: "Gateway から切断されました。", archivedSessionDisabled: "メッセージを送信するには、このセッションを復元してください。", + taskSuggestions: { + eyebrow: "提案されたフォローアップ", + start: "ワークツリーで開始", + starting: "開始中…", + dismiss: "{title} を閉じる", + project: "プロジェクト", + instructions: "指示", + adminRequired: "このプロジェクトからワークツリーを作成するには管理者権限が必要です。", + }, refreshTitle: "チャットデータを更新", settings: "チャット設定", usageRemaining: "残りの使用量", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 6086ded13fe..021f1102485 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -1546,6 +1546,15 @@ export const ko: TranslationMap = { chat: { disconnected: "Gateway와 연결이 끊어졌습니다.", archivedSessionDisabled: "메시지를 보내려면 이 세션을 복원하세요.", + taskSuggestions: { + eyebrow: "제안된 후속 작업", + start: "워크트리에서 시작", + starting: "시작 중…", + dismiss: "{title} 닫기", + project: "프로젝트", + instructions: "지침", + adminRequired: "이 프로젝트에서 워크트리를 만들려면 관리자 권한이 필요합니다.", + }, refreshTitle: "채팅 데이터 새로고침", settings: "채팅 설정", usageRemaining: "남은 사용량", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 261f6a55a51..e1d03b13c33 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -1563,6 +1563,15 @@ export const nl: TranslationMap = { chat: { disconnected: "Verbinding met Gateway verbroken.", archivedSessionDisabled: "Herstel deze sessie om berichten te verzenden.", + taskSuggestions: { + eyebrow: "Voorgestelde vervolgtaak", + start: "Start in een worktree", + starting: "Starten…", + dismiss: "{title} negeren", + project: "Project", + instructions: "Instructies", + adminRequired: "Beheerderstoegang is vereist om vanuit dit project een worktree te maken.", + }, refreshTitle: "Chatgegevens vernieuwen", settings: "Chatinstellingen", usageRemaining: "Resterend gebruik", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 56f46fb2eb9..fd10beae75a 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -1561,6 +1561,16 @@ export const pl: TranslationMap = { chat: { disconnected: "Rozłączono z Gateway.", archivedSessionDisabled: "Przywróć tę sesję, aby wysyłać wiadomości.", + taskSuggestions: { + eyebrow: "Sugerowane zadanie uzupełniające", + start: "Uruchom w drzewie roboczym", + starting: "Uruchamianie…", + dismiss: "Odrzuć {title}", + project: "Projekt", + instructions: "Instrukcje", + adminRequired: + "Dostęp administratora jest wymagany, aby utworzyć drzewo robocze z tego projektu.", + }, refreshTitle: "Odśwież dane czatu", settings: "Ustawienia czatu", usageRemaining: "Pozostałe użycie", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index e7e8b1fabd4..f0670245746 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -1559,6 +1559,15 @@ export const pt_BR: TranslationMap = { chat: { disconnected: "Desconectado do gateway.", archivedSessionDisabled: "Restaure esta sessão para enviar mensagens.", + taskSuggestions: { + eyebrow: "Acompanhamento sugerido", + start: "Iniciar em uma worktree", + starting: "Iniciando…", + dismiss: "Dispensar {title}", + project: "Projeto", + instructions: "Instruções", + adminRequired: "É necessário acesso de administrador para criar uma worktree deste projeto.", + }, refreshTitle: "Atualizar dados do chat", settings: "Configurações do chat", usageRemaining: "Uso restante", diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index d70f02e5bbe..60a6e418029 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -1570,6 +1570,16 @@ export const ru: TranslationMap = { chat: { disconnected: "Отключено от gateway.", archivedSessionDisabled: "Восстановите этот сеанс, чтобы отправлять сообщения.", + taskSuggestions: { + eyebrow: "Предлагаемая следующая задача", + start: "Начать в рабочем дереве", + starting: "Запуск…", + dismiss: "Отклонить {title}", + project: "Проект", + instructions: "Инструкции", + adminRequired: + "Для создания рабочего дерева из этого проекта необходим доступ администратора.", + }, refreshTitle: "Обновить данные чата", settings: "Настройки чата", usageRemaining: "Оставшееся использование", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 0a16cd80af9..df83c0a1b10 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -1523,6 +1523,15 @@ export const th: TranslationMap = { chat: { disconnected: "ตัดการเชื่อมต่อจากเกตเวย์แล้ว", archivedSessionDisabled: "กู้คืนเซสชันนี้เพื่อส่งข้อความ", + taskSuggestions: { + eyebrow: "งานติดตามผลที่แนะนำ", + start: "เริ่มในเวิร์กทรี", + starting: "กำลังเริ่ม…", + dismiss: "ปิด {title}", + project: "โปรเจกต์", + instructions: "คำแนะนำ", + adminRequired: "ต้องมีสิทธิ์ผู้ดูแลระบบเพื่อสร้างเวิร์กทรีจากโปรเจกต์นี้", + }, refreshTitle: "รีเฟรชข้อมูลแชต", settings: "การตั้งค่าแชท", usageRemaining: "การใช้งานคงเหลือ", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 4ad2adcc766..fd9eada813c 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -1563,6 +1563,15 @@ export const tr: TranslationMap = { chat: { disconnected: "Gateway bağlantısı kesildi.", archivedSessionDisabled: "Mesaj göndermek için bu oturumu geri yükleyin.", + taskSuggestions: { + eyebrow: "Önerilen takip görevi", + start: "Çalışma ağacında başlat", + starting: "Başlatılıyor…", + dismiss: "{title} önerisini kapat", + project: "Proje", + instructions: "Talimatlar", + adminRequired: "Bu projeden bir çalışma ağacı oluşturmak için yönetici erişimi gerekir.", + }, refreshTitle: "Sohbet verilerini yenile", settings: "Sohbet ayarları", usageRemaining: "Kalan kullanım", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 23d850343e5..e54faf859e5 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -1559,6 +1559,16 @@ export const uk: TranslationMap = { chat: { disconnected: "Відключено від шлюзу.", archivedSessionDisabled: "Відновіть цей сеанс, щоб надсилати повідомлення.", + taskSuggestions: { + eyebrow: "Запропоноване подальше завдання", + start: "Почати в робочому дереві", + starting: "Запуск…", + dismiss: "Відхилити {title}", + project: "Проєкт", + instructions: "Інструкції", + adminRequired: + "Для створення робочого дерева з цього проєкту потрібен доступ адміністратора.", + }, refreshTitle: "Оновити дані чату", settings: "Налаштування чату", usageRemaining: "Залишок використання", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index d87ed9221dd..c09b64edbe2 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -1548,6 +1548,15 @@ export const vi: TranslationMap = { chat: { disconnected: "Đã ngắt kết nối khỏi gateway.", archivedSessionDisabled: "Khôi phục phiên này để gửi tin nhắn.", + taskSuggestions: { + eyebrow: "Tác vụ tiếp theo được đề xuất", + start: "Bắt đầu trong worktree", + starting: "Đang bắt đầu…", + dismiss: "Bỏ qua {title}", + project: "Dự án", + instructions: "Hướng dẫn", + adminRequired: "Cần quyền quản trị viên để tạo worktree từ dự án này.", + }, refreshTitle: "Làm mới dữ liệu trò chuyện", settings: "Cài đặt trò chuyện", usageRemaining: "Mức sử dụng còn lại", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 382b0dded20..c07d5cf306f 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -1518,6 +1518,15 @@ export const zh_CN: TranslationMap = { chat: { disconnected: "已断开与网关的连接。", archivedSessionDisabled: "恢复此会话以发送消息。", + taskSuggestions: { + eyebrow: "建议的后续任务", + start: "在工作树中开始", + starting: "正在启动…", + dismiss: "忽略 {title}", + project: "项目", + instructions: "说明", + adminRequired: "需要管理员权限才能为此项目创建工作树。", + }, refreshTitle: "刷新聊天数据", settings: "聊天设置", usageRemaining: "剩余用量", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index c3aa1113dfb..d1136cce2c3 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -1520,6 +1520,15 @@ export const zh_TW: TranslationMap = { chat: { disconnected: "已斷開與網關的連接。", archivedSessionDisabled: "還原此工作階段以傳送訊息。", + taskSuggestions: { + eyebrow: "建議的後續任務", + start: "在工作樹中開始", + starting: "正在啟動…", + dismiss: "略過 {title}", + project: "專案", + instructions: "指示", + adminRequired: "需要管理員權限才能為此專案建立工作樹。", + }, refreshTitle: "刷新聊天數據", settings: "聊天設定", usageRemaining: "剩餘用量", diff --git a/ui/src/lib/sessions/create.ts b/ui/src/lib/sessions/create.ts index bc8970b3d1c..b291dd2ccb8 100644 --- a/ui/src/lib/sessions/create.ts +++ b/ui/src/lib/sessions/create.ts @@ -8,6 +8,8 @@ export type SessionCreateParams = { label?: string; model?: string; worktree?: boolean; + cwd?: string; + task?: string; }; export function resolveSessionCreateParams(sessionKey = "", agentId?: string) { diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index d861b4fb1c4..1cf2cb4a5e9 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -1,6 +1,12 @@ import { consume } from "@lit/context"; import { html, LitElement } from "lit"; import { property } from "lit/decorators.js"; +import type { + TaskSuggestion, + TaskSuggestionEvent, + TaskSuggestionsAcceptResult, + TaskSuggestionsListResult, +} from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; import { @@ -8,7 +14,7 @@ import { type ApplicationContext, type ApplicationGatewaySnapshot, } from "../../app/context.ts"; -import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; import { COMMAND_PALETTE_TARGET_EVENT, type CommandPaletteTargetDetail, @@ -137,6 +143,135 @@ class ChatPane extends LitElement { private connectionGeneration = 0; private nativeDraftCleanup: (() => void) | null = null; private readonly unreadPatchGuard = new SessionUnreadPatchGuard(); + private taskSuggestions: TaskSuggestion[] = []; + private readonly taskSuggestionBusyIds = new Set(); + private taskSuggestionsRequestVersion = 0; + + private taskSuggestionMatchesCurrentSession(suggestion: TaskSuggestion): boolean { + const state = this.state; + return Boolean( + state && + uiSessionEventMatches( + { + agentsList: this.context.agents.state.agentsList, + hello: this.context.gateway.snapshot.hello, + sessionKey: state.sessionKey, + }, + suggestion.sessionKey, + suggestion.agentId, + ), + ); + } + + private async refreshTaskSuggestions(): Promise { + const state = this.state; + const client = state?.client; + const requestVersion = ++this.taskSuggestionsRequestVersion; + if ( + !state?.connected || + !client || + !isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.list") + ) { + this.taskSuggestions = []; + this.requestUpdate(); + return; + } + const sessionKey = state.sessionKey; + const agentId = resolveChatAgentId(state); + try { + const result = await client.request("taskSuggestions.list", { + agentId, + }); + if ( + requestVersion !== this.taskSuggestionsRequestVersion || + client !== this.state?.client || + sessionKey !== this.state?.sessionKey + ) { + return; + } + this.taskSuggestions = result.suggestions.filter((suggestion) => + this.taskSuggestionMatchesCurrentSession(suggestion), + ); + this.requestUpdate(); + } catch { + // Suggestions are an optional ephemeral affordance; chat remains usable + // when an older Gateway or a reconnect loses the process-local registry. + // Keep event-delivered cards when a background reconciliation fails. + } + } + + private handleTaskSuggestionEvent(event: TaskSuggestionEvent): void { + if (event.action === "created") { + if (!this.taskSuggestionMatchesCurrentSession(event.suggestion)) { + return; + } + this.taskSuggestions = [ + event.suggestion, + ...this.taskSuggestions.filter((item) => item.id !== event.suggestion.id), + ]; + } else { + this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== event.taskId); + this.taskSuggestionBusyIds.delete(event.taskId); + } + this.requestUpdate(); + // The replacement snapshot includes the event plus unrelated suggestions; + // its request version prevents any older snapshot from overwriting either. + void this.refreshTaskSuggestions(); + } + + private readonly acceptTaskSuggestion = async (suggestion: TaskSuggestion): Promise => { + const state = this.state; + const client = state?.client; + if ( + !state || + !client || + !this.taskSuggestionMatchesCurrentSession(suggestion) || + this.taskSuggestionBusyIds.has(suggestion.id) + ) { + return; + } + const sessionKey = state.sessionKey; + this.taskSuggestionBusyIds.add(suggestion.id); + this.requestUpdate(); + try { + const result = await client.request("taskSuggestions.accept", { + taskId: suggestion.id, + }); + this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id); + if (this.state?.sessionKey === sessionKey) { + this.onPaneSessionChange?.(this.paneId, result.key); + } + } catch (error) { + state.lastError = error instanceof Error ? error.message : String(error); + state.chatError = state.lastError; + } finally { + this.taskSuggestionBusyIds.delete(suggestion.id); + this.requestUpdate(); + } + }; + + private readonly dismissTaskSuggestion = async (suggestion: TaskSuggestion): Promise => { + const state = this.state; + if ( + !state?.client || + !this.taskSuggestionMatchesCurrentSession(suggestion) || + this.taskSuggestionBusyIds.has(suggestion.id) + ) { + return; + } + this.taskSuggestionBusyIds.add(suggestion.id); + this.requestUpdate(); + try { + await state.client.request("taskSuggestions.dismiss", { taskId: suggestion.id }); + this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id); + } catch (error) { + state.lastError = error instanceof Error ? error.message : String(error); + state.chatError = state.lastError; + } finally { + this.taskSuggestionBusyIds.delete(suggestion.id); + this.requestUpdate(); + } + }; private markSessionRead(row: GatewaySessionRow | undefined) { const state = this.state; @@ -196,6 +331,8 @@ class ChatPane extends LitElement { const nextSessionRow = state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey); const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow); resetChatStateForRouteSession(state, nextSessionKey); + this.taskSuggestionsRequestVersion += 1; + this.taskSuggestions = []; this.markSessionRead(nextSessionRow); if (previousSessionKey !== nextSessionKey) { state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel); @@ -206,6 +343,7 @@ class ChatPane extends LitElement { const subscriptionSync = syncSelectedSessionMessageSubscription(state); const historyLoad = loadChatHistory(state); state.requestUpdate(); + void this.refreshTaskSuggestions(); const scheduleHistoryScroll = () => { if (state.sessionKey !== nextSessionKey) { return; @@ -481,6 +619,9 @@ class ChatPane extends LitElement { this.context.gateway.subscribeEvents((event) => { const state = this.state; if (state) { + if (event.event === "task.suggestion" && event.payload) { + this.handleTaskSuggestionEvent(event.payload as TaskSuggestionEvent); + } handlePageGatewayEvent(state, event); } }), @@ -715,6 +856,7 @@ class ChatPane extends LitElement { }); void refreshChatModelAuthStatus(state).finally(() => state.requestUpdate?.()); void state.loadAssistantIdentity(); + void this.refreshTaskSuggestions(); } state.requestUpdate?.(); } @@ -929,6 +1071,16 @@ class ChatPane extends LitElement { onOpenSplitView: this.onOpenSplitView, }), sessionWorkspace: createSessionWorkspaceProps(state), + taskSuggestions: this.taskSuggestions, + taskSuggestionBusyIds: this.taskSuggestionBusyIds, + canAcceptTaskSuggestions: + state.connected && + hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), + canDismissTaskSuggestions: + state.connected && + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null), + onAcceptTaskSuggestion: (suggestion) => void this.acceptTaskSuggestion(suggestion), + onDismissTaskSuggestion: (suggestion) => void this.dismissTaskSuggestion(suggestion), onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target), onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path), onRefresh: () => { diff --git a/ui/src/pages/chat/chat-task-suggestions.test.ts b/ui/src/pages/chat/chat-task-suggestions.test.ts new file mode 100644 index 00000000000..64169a1e22a --- /dev/null +++ b/ui/src/pages/chat/chat-task-suggestions.test.ts @@ -0,0 +1,65 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { TaskSuggestion } from "../../../../packages/gateway-protocol/src/index.js"; +import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts"; + +const suggestion: TaskSuggestion = { + id: "task_123", + title: "Remove stale adapter", + prompt: "Delete the stale adapter and update tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + createdAt: 1, +}; + +describe("chat task suggestions", () => { + it("renders an actionable chip", () => { + const container = document.createElement("div"); + const onAccept = vi.fn(); + const onDismiss = vi.fn(); + render( + renderChatTaskSuggestions({ + suggestions: [suggestion], + busyIds: new Set(), + canAccept: true, + canDismiss: true, + onAccept, + onDismiss, + }), + container, + ); + + expect(container.textContent).toContain("Remove stale adapter"); + expect(container.textContent).toContain("The adapter is unreachable"); + expect(container.textContent).toContain("/repo"); + expect(container.textContent).toContain("Delete the stale adapter and update tests."); + container.querySelector(".task-suggestion__start")?.click(); + container.querySelector(".task-suggestion__dismiss")?.click(); + expect(onAccept).toHaveBeenCalledWith(suggestion); + expect(onDismiss).toHaveBeenCalledWith(suggestion); + }); + + it("hides dismissal without write access and requires admin access to start", () => { + const container = document.createElement("div"); + render( + renderChatTaskSuggestions({ + suggestions: [suggestion], + busyIds: new Set(), + canAccept: false, + canDismiss: false, + onAccept: vi.fn(), + onDismiss: vi.fn(), + }), + container, + ); + + expect(container.querySelector(".task-suggestion__start")?.disabled).toBe( + true, + ); + expect(container.querySelector(".task-suggestion__dismiss")).toBeNull(); + }); +}); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 943c5749d5f..7f8d352d6f2 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -2,6 +2,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { styleMap } from "lit/directives/style-map.js"; +import type { TaskSuggestion } from "../../../../packages/gateway-protocol/src/index.js"; import type { SessionsListResult } from "../../api/types.ts"; import type { ChatSendShortcut } from "../../app/settings.ts"; import { icons } from "../../components/icons.ts"; @@ -24,12 +25,13 @@ import { renderSessionWorkspaceRail, type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; -import "./components/chat-sidebar.ts"; import type { DetailFullMessageResult, SidebarContent, SidebarFullMessageRequest, } from "./components/chat-sidebar.ts"; +import "./components/chat-sidebar.ts"; +import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts"; import { isChatThreadSearchOpen, renderChatPinnedMessages, @@ -143,6 +145,12 @@ export type ChatProps = { onClearReply?: () => void; onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void; sessionWorkspace?: SessionWorkspaceProps; + taskSuggestions?: TaskSuggestion[]; + taskSuggestionBusyIds?: ReadonlySet; + canAcceptTaskSuggestions?: boolean; + canDismissTaskSuggestions?: boolean; + onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void; + onDismissTaskSuggestion?: (suggestion: TaskSuggestion) => void; }; export function resetChatViewState(paneId?: string) { @@ -343,7 +351,16 @@ export function renderChat(props: ChatProps) { class="chat-main" style="flex: ${sidebarOpen ? `0 1 ${splitRatio * 100}%` : "1 1 100%"}" > - ${thread} ${chatColumnFooter} + ${thread} + ${renderChatTaskSuggestions({ + suggestions: props.taskSuggestions ?? [], + busyIds: props.taskSuggestionBusyIds ?? new Set(), + canAccept: props.canAcceptTaskSuggestions === true, + canDismiss: props.canDismissTaskSuggestions === true, + onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion), + onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion), + })} + ${chatColumnFooter} ${sidebarOpen diff --git a/ui/src/pages/chat/components/chat-task-suggestions.ts b/ui/src/pages/chat/components/chat-task-suggestions.ts new file mode 100644 index 00000000000..8b0dc970c36 --- /dev/null +++ b/ui/src/pages/chat/components/chat-task-suggestions.ts @@ -0,0 +1,72 @@ +// Chat UI cards for model-proposed follow-up tasks. +import { html, nothing } from "lit"; +import type { TaskSuggestion } from "../../../../../packages/gateway-protocol/src/index.js"; +import { icons } from "../../../components/icons.ts"; +import { t } from "../../../i18n/index.ts"; + +export function renderChatTaskSuggestions(props: { + suggestions: TaskSuggestion[]; + busyIds: ReadonlySet; + canAccept: boolean; + canDismiss: boolean; + onAccept: (suggestion: TaskSuggestion) => void; + onDismiss: (suggestion: TaskSuggestion) => void; +}) { + if (props.suggestions.length === 0) { + return nothing; + } + return html` +
+ ${props.suggestions.map((suggestion) => { + const busy = props.busyIds.has(suggestion.id); + return html` +
+ +
+
${t("chat.taskSuggestions.eyebrow")}
+
${suggestion.title}
+
${suggestion.tldr}
+
+
+ ${t("chat.taskSuggestions.project")} + ${suggestion.cwd} +
+
+ ${t("chat.taskSuggestions.instructions")} +
${suggestion.prompt}
+
+
+
+
+ + ${props.canDismiss + ? html` + + ` + : nothing} +
+
+ `; + })} +
+ `; +} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 093f762f9cc..83103218c81 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -834,6 +834,137 @@ openclaw-chat-page { margin: 8px auto 14px; } +.task-suggestions { + display: grid; + gap: 8px; + max-height: min(40vh, 24rem); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 14px 10px; +} + +.task-suggestion { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); +} + +.task-suggestion__icon { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 32px; + place-items: center; + border-radius: 10px; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.task-suggestion__icon svg, +.task-suggestion__start svg { + width: 16px; + height: 16px; +} + +.task-suggestion__body { + min-width: 0; + flex: 1; +} + +.task-suggestion__eyebrow { + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.task-suggestion__title { + margin-top: 2px; + color: var(--text); + font-weight: 650; +} + +.task-suggestion__summary { + margin-top: 3px; + color: var(--muted); + font-size: 13px; + line-height: 1.35; +} + +.task-suggestion__details { + display: grid; + gap: 7px; + margin-top: 10px; +} + +.task-suggestion__detail { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 8px; + align-items: start; + color: var(--muted); + font-size: 11px; +} + +.task-suggestion__detail > span { + padding-top: 3px; + font-weight: 650; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.task-suggestion__detail code, +.task-suggestion__detail pre { + min-width: 0; + max-height: 9rem; + margin: 0; + padding: 5px 7px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--bg) 70%, transparent); + color: var(--text); + font: 11px/1.4 var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.task-suggestion__actions { + display: flex; + align-items: center; + gap: 6px; +} + +.task-suggestion__start { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +@media (max-width: 640px) { + .task-suggestion { + align-items: flex-start; + flex-wrap: wrap; + } + + .task-suggestion__actions { + width: 100%; + justify-content: flex-end; + padding-left: 44px; + } + + .task-suggestion__detail { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + } +} + .agent-chat__input { --chat-box-inset: 8px;