From 7eb4bb07f491b15f22f93cc28b126a2a2abe45dd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:44:58 +0300 Subject: [PATCH 01/43] fix(sync): preserve optimistic entries for stale loads --- packages/ui/src/sync/use-sync.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index a53ebd6984..9abdd76bc4 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -360,16 +360,16 @@ export function useSync() { mode: "replace" | "prepend" | undefined, isStale?: () => boolean, ) => { + if (isStale?.()) { + return { messages: [], cursor: page.cursor, complete: page.complete } + } + const items = getOptimistic(sessionID) const merged = mergeOptimisticPage(page, items) for (const messageID of merged.confirmed) { clearOptimistic(sessionID, messageID) } - if (isStale?.()) { - return { messages: [], cursor: merged.cursor, complete: merged.complete } - } - const current = store.getState() const materialized = materializeSessionSnapshots( current, From 2d880d7a9ea54dee89508a9322a171dc0230c97a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:45:26 +0300 Subject: [PATCH 02/43] fix(sync): defer incomplete assistant-only pages --- packages/ui/src/sync/use-sync.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 9abdd76bc4..9a50ad014d 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -447,8 +447,9 @@ export function useSync() { // user sees content as soon as a user boundary appears, instead of // waiting for the full expansion sequence before the first paint. if (!options?.before && !page.complete && !hasUserMessage(page.session)) { - for (const nextLimit of getInitialPageExpansionLimits()) { - if (nextLimit <= limit) continue + const expansionLimits = getInitialPageExpansionLimits().filter((nextLimit) => nextLimit > limit) + for (let index = 0; index < expansionLimits.length; index += 1) { + const nextLimit = expansionLimits[index] if (options?.isStale?.()) { setMetaFor(sessionID, { loading: false }) return @@ -458,12 +459,22 @@ export function useSync() { setMetaFor(sessionID, { loading: false }) return } - committed = commitMessagesToStore(expandedPage, options?.mode, options?.isStale) + const hasBoundary = hasUserMessage(expandedPage.session) + const isFinalExpansion = index === expansionLimits.length - 1 + if (expandedPage.complete || hasBoundary || isFinalExpansion) { + committed = commitMessagesToStore(expandedPage, options?.mode, options?.isStale) + } else { + committed = { + messages: expandedPage.session, + cursor: expandedPage.cursor, + complete: expandedPage.complete, + } + } if (options?.isStale?.()) { setMetaFor(sessionID, { loading: false }) return } - if (expandedPage.complete || hasUserMessage(expandedPage.session)) break + if (expandedPage.complete || hasBoundary) break } } From 15be4eb0ca8c31f71c639a2a97575d78ef5b4418 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:46:41 +0300 Subject: [PATCH 03/43] fix(auth): reject spoofed local host headers --- .../web/server/lib/opencode/core-routes.test.js | 10 ++++++++++ packages/web/server/lib/opencode/tunnel-auth.js | 17 ++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index 761024eb28..3119135ea6 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -700,4 +700,14 @@ describe('client auth routes', () => { expect(dependencies.uiAuthController.handlePasskeyStatus).toHaveBeenCalledTimes(1); }); + + it('does not trust a private Host header from a public socket peer', () => { + const tunnelAuthController = createTunnelAuth(); + tunnelAuthController.setActiveTunnel({ tunnelId: 'tunnel-1', publicUrl: 'https://tunnel.example.com' }); + + expect(tunnelAuthController.classifyRequestScope({ + headers: { host: '192.168.1.5:57123' }, + socket: { remoteAddress: '203.0.113.10' }, + })).toBe('unknown-public'); + }); }); diff --git a/packages/web/server/lib/opencode/tunnel-auth.js b/packages/web/server/lib/opencode/tunnel-auth.js index 69cd6aab33..5326b8a541 100644 --- a/packages/web/server/lib/opencode/tunnel-auth.js +++ b/packages/web/server/lib/opencode/tunnel-auth.js @@ -172,19 +172,10 @@ const isLocalHost = (host, req) => { return false; } - if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { - return true; - } - - if (isPrivateOrLoopbackIp(host)) { - return true; - } - - if (host === 'host.docker.internal') { - return isPrivateOrLoopbackIp(getSocketRemoteIp(req)); - } - - return false; + const isLocalHostname = host === 'localhost' + || host === 'host.docker.internal' + || isPrivateOrLoopbackIp(host); + return isLocalHostname && isPrivateOrLoopbackIp(getSocketRemoteIp(req)); }; const getClientIp = (req) => { From f19d02dcbfeb7baea61f1272e0ea9e3f1e44a64a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:47:22 +0300 Subject: [PATCH 04/43] fix(sidebar): avoid duplicate refresh loads --- packages/ui/src/components/layout/SidebarFilesTree.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 62eb88c0ef..2ad972d2f6 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -644,10 +644,8 @@ export const SidebarFilesTree: React.FC = () => { const pathsToRefresh = [root, ...normalizedExpanded]; loadedDirsRef.current = new Set(loadedDirsRef.current); - inFlightDirsRef.current = new Set(inFlightDirsRef.current); for (const dirPath of pathsToRefresh) { loadedDirsRef.current.delete(dirPath); - inFlightDirsRef.current.delete(dirPath); } setLoadErrorsByDir((prev) => { From 3a2220438eb8dbd650b0d14a668d5aebf937f896 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:48:21 +0300 Subject: [PATCH 05/43] fix(chat): retain latest overlapping message data --- .../ui/src/components/chat/MessageList.tsx | 17 +++++++---- .../baseDisplayMessagesDedup.test.ts | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 3ffb37fcbc..e5f89abb4c 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1345,12 +1345,15 @@ const MessageList = React.forwardRef(({ const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => { const seenIds = new Set(); + const latestById = new Map(); const dedupedMessages: ChatMessageEntry[] = []; - // Deduplicate from head to tail (oldest to newest) to preserve chronological - // order during history pagination (prepend). Older messages have smaller - // time-sortable IDs and appear first in the array. Keeping the first - // occurrence ensures prepended history isn't incorrectly discarded in favor - // of newer duplicates from the existing view. + for (const message of messages) { + const messageId = message.info?.id; + if (typeof messageId === 'string') latestById.set(messageId, message); + } + + // Preserve the first occurrence's chronological position, but use the last + // value because prepended history can overlap with newer live store data. for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; const messageId = message.info?.id; @@ -1360,7 +1363,9 @@ const MessageList = React.forwardRef(({ } seenIds.add(messageId); } - dedupedMessages.push(getNormalizedMessageForDisplay(message)); + dedupedMessages.push(getNormalizedMessageForDisplay( + typeof messageId === 'string' ? latestById.get(messageId) ?? message : message, + )); } const output: ChatMessageEntry[] = []; diff --git a/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts b/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts index b2c275f5a8..5445d24e21 100644 --- a/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts +++ b/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts @@ -4,12 +4,18 @@ import type { ChatMessageEntry } from '../lib/turns/types'; /** * Dedup logic extracted from MessageList.tsx baseDisplayMessages. * Tests verify that deduplication preserves chronological order - * and keeps the first occurrence of each message ID. + * while keeping the latest value for each message ID. */ function deduplicateMessages(messages: ChatMessageEntry[]): ChatMessageEntry[] { const seenIds = new Set(); + const latestById = new Map(); const dedupedMessages: ChatMessageEntry[] = []; + for (const message of messages) { + const messageId = message.info?.id; + if (typeof messageId === 'string') latestById.set(messageId, message); + } + for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; const messageId = message.info?.id; @@ -19,7 +25,9 @@ function deduplicateMessages(messages: ChatMessageEntry[]): ChatMessageEntry[] { } seenIds.add(messageId); } - dedupedMessages.push(message); + dedupedMessages.push( + typeof messageId === 'string' ? latestById.get(messageId) ?? message : message, + ); } return dedupedMessages; @@ -48,7 +56,7 @@ function createMessageEntry({ } describe('baseDisplayMessages dedup', () => { - test('removes duplicate message IDs, keeping the first occurrence', () => { + test('removes duplicate message IDs, keeping the latest value', () => { const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 }); const msg2 = createMessageEntry({ id: 'msg-2', role: 'assistant', createdAt: 2 }); const msg1Duplicate = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 3 }); @@ -57,6 +65,7 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(2); expect(result[0]?.info.id).toBe('msg-1'); + expect(result[0]?.info.time.created).toBe(3); expect(result[1]?.info.id).toBe('msg-2'); }); @@ -106,7 +115,7 @@ describe('baseDisplayMessages dedup', () => { expect(result[0]?.info.id).toBe('msg-1'); }); - test('all messages sharing same ID keeps only first', () => { + test('all messages sharing same ID keeps only the latest value', () => { const msg1 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 1 }); const msg2 = createMessageEntry({ id: 'same-id', role: 'assistant', createdAt: 2 }); const msg3 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 3 }); @@ -116,6 +125,7 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(1); expect(result[0]?.info.id).toBe('same-id'); expect(result[0]?.info.role).toBe('user'); + expect(result[0]?.info.time.created).toBe(3); }); test('deduplication scenario: prepend history with overlapping IDs', () => { @@ -133,10 +143,10 @@ describe('baseDisplayMessages dedup', () => { const result = deduplicateMessages(messages); - // Should keep all unique IDs, first occurrence wins + // Keep the prepended ordering position while retaining the existing value. expect(result).toHaveLength(3); expect(result[0]?.info.id).toBe('msg-0'); // prepended (oldest) - expect(result[1]?.info.id).toBe('msg-1'); // first occurrence from prepend + expect(result[1]).toBe(existingMsg1); expect(result[2]?.info.id).toBe('msg-2'); // existing }); @@ -149,16 +159,16 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(1); expect(result[0]?.info.id).toBe('msg-1'); + expect(result[0]?.info.time.created).toBe(3); }); - test('preserves first occurrence when duplicates appear later', () => { + test('preserves first position while using a later duplicate value', () => { const msg1First = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 }); const msg1Later = createMessageEntry({ id: 'msg-1', role: 'assistant', createdAt: 5 }); const result = deduplicateMessages([msg1First, msg1Later]); expect(result).toHaveLength(1); - // Should keep the first occurrence (createdAt: 1), not the later one - expect(result[0]?.info.role).toBe('user'); + expect(result[0]?.info.role).toBe('assistant'); }); }); From c2bf96bb60e3f0ce65206ebcfdf8718137cd1904 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:49:28 +0300 Subject: [PATCH 06/43] fix(worktree): refresh changed discovery metadata --- .../src/lib/worktrees/worktreeManager.test.ts | 12 +++++++++ .../ui/src/lib/worktrees/worktreeManager.ts | 25 +++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 677bfd9eff..944c3f59f2 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -153,6 +153,18 @@ describe('worktreeMapsEqual', () => { expect(worktreeMapsEqual(a, b)).toBe(false); }); + test('returns false when head state changes without a branch change', () => { + const a = new Map([['/repo', [wt('/r/main', '', { headState: 'unborn' })]]]); + const b = new Map([['/repo', [wt('/r/main', '', { headState: 'detached' })]]]); + expect(worktreeMapsEqual(a, b)).toBe(false); + }); + + test('returns false when discovered display metadata changes', () => { + const a = new Map([['/repo', [wt('/r/main', '', { name: 'old', label: 'old' })]]]); + const b = new Map([['/repo', [wt('/r/main', '', { name: 'new', label: 'new' })]]]); + expect(worktreeMapsEqual(a, b)).toBe(false); + }); + test('returns false when paths differ', () => { const a = new Map([['/repo', [wt('/r/main', 'main')]]]); const b = new Map([['/repo', [wt('/r/other', 'main')]]]); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 4377a263f4..bccd773a52 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -231,7 +231,7 @@ const toCreatePayload = (args: { /** * Compare two worktree-by-project maps for equality. - * Compares per-element `path` and `branch` (not reference equality) + * Compares discovery-owned metadata (not reference equality) * because readStableProjectWorktrees creates new object instances on * each call, making reference checks always report changed. * @@ -246,22 +246,27 @@ const toCreatePayload = (args: { * flow through `setStoredWorktreeStatus`, which writes a new Map * reference that the persist subscriber picks up directly. * - * Generic over `T extends { path: string; branch: string }` so the - * helper documents its equality contract at the type level and - * stays reusable for any future map-of-arrays shape that has both - * fields. */ -export const worktreeMapsEqual = ( - a: Map, - b: Map, +export const worktreeMapsEqual = ( + a: Map, + b: Map, ): boolean => { if (a.size !== b.size) return false; for (const [key, value] of a) { const existing = b.get(key); if (!existing || existing.length !== value.length) return false; for (let i = 0; i < value.length; i++) { - if (value[i].path !== existing[i].path) return false; - if (value[i].branch !== existing[i].branch) return false; + const next = value[i]; + const current = existing[i]; + if (next.path !== current.path + || next.branch !== current.branch + || next.name !== current.name + || next.label !== current.label + || next.projectDirectory !== current.projectDirectory + || next.worktreeRoot !== current.worktreeRoot + || next.headState !== current.headState + || next.worktreeSource !== current.worktreeSource + || next.source !== current.source) return false; } } return true; From c8850a4d071595d6ddb219ff95bdf7b7775d34b7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:51:36 +0300 Subject: [PATCH 07/43] fix(sidebar): preserve pins after partial session loads --- packages/ui/src/components/session/SessionSidebar.tsx | 3 ++- .../session/sidebar/hooks/useSidebarPersistence.ts | 8 ++++---- packages/ui/src/stores/useGlobalSessionsStore.ts | 5 ++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 3ea80c8370..86813fe45c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -320,6 +320,7 @@ export const SessionSidebar: React.FC = ({ const liveSessions = useAllLiveSessions(); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); + const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -539,7 +540,7 @@ export const SessionSidebar: React.FC = ({ const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({ isVSCode, - hasLoadedGlobalSessions, + hasAuthoritativeGlobalSessions, safeStorage, keys: { sessionExpanded: SESSION_EXPANDED_STORAGE_KEY, diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts index 13519d9a7f..3a4a21460d 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts @@ -33,7 +33,7 @@ const LEGACY_EXPANSION_CONTEXT_PREFIXES = [ type Args = { isVSCode: boolean; - hasLoadedGlobalSessions: boolean; + hasAuthoritativeGlobalSessions: boolean; safeStorage: SafeStorageLike; keys: Keys; sessions: Session[]; @@ -49,7 +49,7 @@ type Args = { export const useSidebarPersistence = (args: Args) => { const { isVSCode, - hasLoadedGlobalSessions, + hasAuthoritativeGlobalSessions, safeStorage, keys, sessions, @@ -151,14 +151,14 @@ export const useSidebarPersistence = (args: Args) => { }, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]); React.useEffect(() => { - if (!hasLoadedGlobalSessions) { + if (!hasAuthoritativeGlobalSessions) { return; } setPinnedSessionIds((prev) => { return prunePinnedSessionIds(sessions, prev); }); - }, [hasLoadedGlobalSessions, sessions, setPinnedSessionIds]); + }, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]); React.useEffect(() => { try { diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 2599e60849..7acfd7636a 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -420,7 +420,10 @@ export const useGlobalSessionsStore = create((set, get) => // instance — drop it. return { activeSessions: [], archivedSessions: [] }; } - set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready')); + const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled' + ? 'ready' + : 'error'; + set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, status)); return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; } catch (error) { if (generation !== loadGeneration) { From e250fca466f8d9e7d0eddb769b4268a9a7abf82a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:54:46 +0300 Subject: [PATCH 08/43] fix(session): do not block draft creation on auto-accept --- .../ui/src/sync/__tests__/issue-2039.test.ts | 1 + packages/ui/src/sync/session-ui-store.ts | 17 ++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 00fc232f8b..8197823c1c 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -323,6 +323,7 @@ describe("issue 2039 draft auto-accept", () => { providerID: "provider", modelID: "model", }) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(result?.sessionId).toBe("ses_issue_2039") expect(createSessionCalls).toHaveLength(1) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index aa9d6e5796..578b108cb5 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -488,19 +488,18 @@ export async function materializeOpenDraftSession(selection: { useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant) } - if (draftPermissionAutoAcceptEnabled) { - try { - const { usePermissionStore } = await import("@/stores/permissionStore") - await usePermissionStore.getState().setSessionAutoAccept(created.id, true) - } catch (error) { - console.warn("Failed to apply draft permission auto-accept to new session:", error) - } - } - store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) store.setCurrentSession(created.id, createdDirectory) + if (draftPermissionAutoAcceptEnabled) { + void import("@/stores/permissionStore") + .then(({ usePermissionStore }) => usePermissionStore.getState().setSessionAutoAccept(created.id, true)) + .catch((error) => { + console.warn("Failed to apply draft permission auto-accept to new session:", error) + }) + } + return { sessionId: created.id, directory: createdDirectory, From c387fb21382b702f592855cdc486c977a0e22d17 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 01:12:58 +0300 Subject: [PATCH 09/43] fix(queue): back off failed queued auto-sends --- .../hooks/useQueuedMessageAutoSend.test.ts | 21 ++++++++++++ .../ui/src/hooks/useQueuedMessageAutoSend.ts | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index 073448342d..fad8d66c48 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -29,6 +29,8 @@ mock.module('@/sync/session-ui-store', () => ({ import { buildQueuedAutoSendPayload, + getQueuedAutoSendRetryDelayMs, + isQueuedAutoSendBackedOff, sendQueuedAutoSendPayload, shouldDispatchQueuedAutoSend, } from './useQueuedMessageAutoSend'; @@ -49,6 +51,25 @@ describe('shouldDispatchQueuedAutoSend', () => { }); }); +describe('queued auto-send retry backoff', () => { + test('delay grows exponentially and is capped', () => { + expect(getQueuedAutoSendRetryDelayMs(1)).toBe(2000); + expect(getQueuedAutoSendRetryDelayMs(2)).toBe(4000); + expect(getQueuedAutoSendRetryDelayMs(3)).toBe(8000); + expect(getQueuedAutoSendRetryDelayMs(10)).toBe(60000); + expect(getQueuedAutoSendRetryDelayMs(100)).toBe(60000); + }); + + test('backs off only the failed message within its window', () => { + const failure = { messageId: 'queued-1', failures: 1, nextAttemptAt: 10_000 }; + + expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 9_999)).toBe(true); + expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 10_000)).toBe(false); + expect(isQueuedAutoSendBackedOff(failure, 'queued-2', 9_999)).toBe(false); + expect(isQueuedAutoSendBackedOff(undefined, 'queued-1', 0)).toBe(false); + }); +}); + describe('buildQueuedAutoSendPayload', () => { beforeEach(() => { visibleAgents = []; diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index ee0c36467d..894bdbdaa4 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -13,6 +13,24 @@ type SessionStatusType = 'idle' | 'busy' | 'retry'; const RECENT_ABORT_WINDOW_MS = 2000; +const AUTO_SEND_RETRY_BASE_DELAY_MS = 2000; +const AUTO_SEND_RETRY_MAX_DELAY_MS = 60000; + +export type QueuedAutoSendFailure = { + messageId: string; + failures: number; + nextAttemptAt: number; +}; + +export const getQueuedAutoSendRetryDelayMs = (failures: number): number => + Math.min(AUTO_SEND_RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), AUTO_SEND_RETRY_MAX_DELAY_MS); + +export const isQueuedAutoSendBackedOff = ( + failure: QueuedAutoSendFailure | undefined, + messageId: string, + now: number, +): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt; + const hasRecentAbort = (sessionId: string): boolean => { const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId); if (!abortRecord) { @@ -124,6 +142,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? const sessionStatusRecord = useDirectorySync((state) => state.session_status); const inFlightSessionsRef = React.useRef>(new Set()); + const sendFailuresRef = React.useRef>(new Map()); const previousStatusRef = React.useRef>(new Map()); const autoReviewBlockedSessionsRef = React.useRef>(new Set()); @@ -157,6 +176,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? return; } + const failure = sendFailuresRef.current.get(sessionId); + if (failure && failure.messageId !== payload.queuedMessageId) { + sendFailuresRef.current.delete(sessionId); + } else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { + return; + } + // Use send config captured at queue time; fall back to current config const captured = payload.sendConfig; const resolved = captured?.providerID && captured?.modelID @@ -176,8 +202,16 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? variant: resolved.variant, }); useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId); + sendFailuresRef.current.delete(sessionId); } catch (error) { console.warn('[queue] queued auto-send failed:', error); + const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0; + const failures = priorFailures + 1; + sendFailuresRef.current.set(sessionId, { + messageId: payload.queuedMessageId, + failures, + nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures), + }); } finally { inFlightSessionsRef.current.delete(sessionId); } From 56cf5e29fa063ffaf8aae7ed72d37db14bfce374 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 01:23:22 +0300 Subject: [PATCH 10/43] feat: session goals - server-driven goal loop with independent small-model audit (#2148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arm the target button in the composer and the next prompt becomes a goal: the server keeps the session working toward it (idle tick -> small-model audit -> continuation) until the objective is verifiably complete, blocked, or out of budget — even with the UI closed. Server (packages/web/server/lib/session-goal): - event-driven loop on the global SSE hub; goal state lives in session.metadata.openchamber.goal (merge-safe patches, stale-write guard by goal id), so it survives restarts and syncs to every client for free - the small-model audit (objective + last assistant turn only, language pinned to the objective) is the sole termination authority; blocked needs 3 consecutive verdicts, audit outages tolerate one unaudited continuation then stop the goal as resumable-blocked - hard stops: optional token budget, auto-continuation cap (Resume grants a fresh allowance), turn errors; user abort pauses the goal instead of blocking it, and resuming over an aborted tail nudges immediately - token accounting as a snapshot of the latest turn (input + cache.read + output), goal-relative via a creation baseline and segmented across compactions; a compaction summary skips the audit and continues - continuations reuse the session's own provider/model/agent/variant UI: - three-mode target button (arm / disarm / manage dialog), informational goal strip with inline pause/resume and an Evaluating indicator, sidebar state glyph, objective length counter (2000-char server clamp), read-only completed goals - goal entry points: composer (sessions and drafts), start-new-session- from-answer dialog, plan implement dialog (plan content becomes the objective), scheduled tasks (Run as goal + budget) - Settings -> Chat -> Goal: feature toggle + default token budget with three-layer parity (web server, client persistence, VS Code bridge); VS Code renders goal state but hides the entry points (the loop runs in the web server only) Notifications: per-turn "ready" notifications are suppressed while a goal is active; settling sends one final notification (desktop, web-push, APNs generic titles with the session name as body) honoring the completion toggle. Error/question/permission notifications are untouched. Docs: user guide (session-goals) in all 9 locales + sidebar entry, scheduled-tasks cross-reference, server module DOCUMENTATION.md. --- .../docs/content/docs/es/scheduled-tasks.mdx | 2 + .../docs/content/docs/es/session-goals.mdx | 73 ++ .../docs/content/docs/fr/scheduled-tasks.mdx | 2 + .../docs/content/docs/fr/session-goals.mdx | 73 ++ .../docs/content/docs/ja/scheduled-tasks.mdx | 2 + .../docs/content/docs/ja/session-goals.mdx | 73 ++ .../docs/content/docs/ko/scheduled-tasks.mdx | 2 + .../docs/content/docs/ko/session-goals.mdx | 73 ++ .../docs/content/docs/pl/scheduled-tasks.mdx | 2 + .../docs/content/docs/pl/session-goals.mdx | 73 ++ .../content/docs/pt-br/scheduled-tasks.mdx | 2 + .../docs/content/docs/pt-br/session-goals.mdx | 73 ++ .../docs/content/docs/scheduled-tasks.mdx | 2 + packages/docs/content/docs/session-goals.mdx | 73 ++ .../docs/content/docs/uk/scheduled-tasks.mdx | 2 + .../docs/content/docs/uk/session-goals.mdx | 73 ++ .../content/docs/zh-cn/scheduled-tasks.mdx | 2 + .../docs/content/docs/zh-cn/session-goals.mdx | 73 ++ packages/docs/sidebar.config.json | 14 + packages/ui/src/components/chat/ChatInput.tsx | 29 + .../src/components/chat/SessionGoalButton.tsx | 140 ++++ .../src/components/chat/SessionGoalDialog.tsx | 189 +++++ .../ui/src/components/chat/SessionGoalRow.tsx | 108 +++ packages/ui/src/components/icon/sprite.ts | 4 +- .../sections/openchamber/OpenChamberPage.tsx | 2 +- .../openchamber/OpenChamberVisualSettings.tsx | 83 +- .../components/session/ForkSessionDialog.tsx | 80 +- .../session/ScheduledTaskEditorDialog.tsx | 56 ++ .../src/components/session/TodoSendDialog.tsx | 30 +- .../session/sidebar/SessionNodeItem.tsx | 23 +- packages/ui/src/components/views/PlanView.tsx | 17 +- packages/ui/src/hooks/useSessionGoal.ts | 21 + packages/ui/src/lib/appearanceAutoSave.ts | 18 + packages/ui/src/lib/desktop.ts | 3 + .../ui/src/lib/i18n/messages/en.settings.ts | 6 + packages/ui/src/lib/i18n/messages/en.ts | 37 + .../ui/src/lib/i18n/messages/es.settings.ts | 6 + packages/ui/src/lib/i18n/messages/es.ts | 37 + .../ui/src/lib/i18n/messages/fr.settings.ts | 6 + packages/ui/src/lib/i18n/messages/fr.ts | 37 + .../ui/src/lib/i18n/messages/ja.settings.ts | 6 + packages/ui/src/lib/i18n/messages/ja.ts | 37 + .../ui/src/lib/i18n/messages/ko.settings.ts | 6 + packages/ui/src/lib/i18n/messages/ko.ts | 37 + .../ui/src/lib/i18n/messages/pl.settings.ts | 6 + packages/ui/src/lib/i18n/messages/pl.ts | 37 + .../src/lib/i18n/messages/pt-BR.settings.ts | 6 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 37 + .../ui/src/lib/i18n/messages/uk.settings.ts | 6 + packages/ui/src/lib/i18n/messages/uk.ts | 37 + .../src/lib/i18n/messages/zh-CN.settings.ts | 6 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 37 + .../src/lib/i18n/messages/zh-TW.settings.ts | 6 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 37 + packages/ui/src/lib/messages/executionMeta.ts | 11 + packages/ui/src/lib/persistence.ts | 18 + packages/ui/src/lib/scheduledTasksApi.ts | 2 + packages/ui/src/lib/sessionGoalActions.ts | 124 +++ packages/ui/src/lib/sessionGoalMetadata.ts | 74 ++ .../ui/src/lib/sessionGoalPresentation.ts | 20 + packages/ui/src/lib/settings/search.ts | 14 + .../ui/src/stores/useSessionGoalArmStore.ts | 29 + packages/ui/src/stores/useUIStore.ts | 24 + packages/ui/src/sync/session-ui-store.ts | 49 ++ .../vscode/src/bridge-settings-runtime.ts | 15 + packages/web/server/index.js | 43 ++ .../web/server/lib/notifications/runtime.js | 70 ++ .../server/lib/opencode/settings-helpers.js | 9 + .../server/lib/opencode/shutdown-runtime.js | 2 + .../web/server/lib/projects/project-config.js | 8 + .../web/server/lib/scheduled-tasks/runtime.js | 57 ++ .../server/lib/session-goal/DOCUMENTATION.md | 149 ++++ .../web/server/lib/session-goal/runtime.js | 719 ++++++++++++++++++ 73 files changed, 3330 insertions(+), 29 deletions(-) create mode 100644 packages/docs/content/docs/es/session-goals.mdx create mode 100644 packages/docs/content/docs/fr/session-goals.mdx create mode 100644 packages/docs/content/docs/ja/session-goals.mdx create mode 100644 packages/docs/content/docs/ko/session-goals.mdx create mode 100644 packages/docs/content/docs/pl/session-goals.mdx create mode 100644 packages/docs/content/docs/pt-br/session-goals.mdx create mode 100644 packages/docs/content/docs/session-goals.mdx create mode 100644 packages/docs/content/docs/uk/session-goals.mdx create mode 100644 packages/docs/content/docs/zh-cn/session-goals.mdx create mode 100644 packages/ui/src/components/chat/SessionGoalButton.tsx create mode 100644 packages/ui/src/components/chat/SessionGoalDialog.tsx create mode 100644 packages/ui/src/components/chat/SessionGoalRow.tsx create mode 100644 packages/ui/src/hooks/useSessionGoal.ts create mode 100644 packages/ui/src/lib/sessionGoalActions.ts create mode 100644 packages/ui/src/lib/sessionGoalMetadata.ts create mode 100644 packages/ui/src/lib/sessionGoalPresentation.ts create mode 100644 packages/ui/src/stores/useSessionGoalArmStore.ts create mode 100644 packages/web/server/lib/session-goal/DOCUMENTATION.md create mode 100644 packages/web/server/lib/session-goal/runtime.js diff --git a/packages/docs/content/docs/es/scheduled-tasks.mdx b/packages/docs/content/docs/es/scheduled-tasks.mdx index b7fc2e4e09..e199dc4950 100644 --- a/packages/docs/content/docs/es/scheduled-tasks.mdx +++ b/packages/docs/content/docs/es/scheduled-tasks.mdx @@ -20,6 +20,8 @@ Una tarea programada ejecuta un prompt por ti según una programación; por ejem Puedes ejecutar cualquier tarea de inmediato con **run now** para comprobar que hace lo que esperas. +Marca **Ejecutar como objetivo** para que la ejecución persiga su prompt hasta completarlo en lugar de detenerse tras una respuesta — consulta [Objetivos de sesión](/session-goals/). + ## Cómo se ve el éxito Después de una ejecución, la tarea muestra cuándo se ejecutó por última vez, si tuvo éxito y un enlace a la sesión que creó. Si una ejecución falla, el error también se muestra ahí. diff --git a/packages/docs/content/docs/es/session-goals.mdx b/packages/docs/content/docs/es/session-goals.mdx new file mode 100644 index 0000000000..ee93021def --- /dev/null +++ b/packages/docs/content/docs/es/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Objetivos de sesión +description: Convierte un prompt en un objetivo hacia el que el agente trabaja automáticamente. +--- + +# Objetivos de sesión + +Un objetivo convierte un solo prompt en una línea de meta. En lugar de empujar al agente con "continúa" tras cada respuesta, defines el objetivo una vez — y OpenChamber mantiene la sesión trabajando hacia él automáticamente, comprobando el progreso con un auditor independiente después de cada turno. Sigue funcionando incluso mientras no estás. + +## Iniciar un objetivo + +1. Pulsa el botón de diana en el compositor. Se ilumina — el modo objetivo está armado. +2. Escribe tu prompt y envíalo. Ese mensaje se convierte en el objetivo. + +Funciona igual en una sesión existente y en un borrador de sesión nueva: arma la diana, escribe el primer mensaje, envía — la nueva sesión arranca con el objetivo ya activo. + +### Más formas de iniciar un objetivo + +- **Desde una respuesta del agente**: en el diálogo "Start new session from this answer", marca **Ejecutar como objetivo** — la respuesta se entrega como una tarea que la nueva sesión ejecuta hasta completarla (combínalo con **Create worktree** para una ejecución aislada). +- **Desde un plan**: al implementar un plan guardado en una sesión o worktree nuevos, marca **Ejecutar como objetivo** en el diálogo. El objetivo lleva el contenido del plan, así que el auditor juzga el progreso contra el plan real. +- **Según un horario**: marca **Ejecutar como objetivo** en una [tarea programada](/scheduled-tasks/) para que las ejecuciones recurrentes persigan su prompt hasta completarlo. + +## Escribe un objetivo autocontenido + +El auditor de progreso solo ve tu objetivo y la última respuesta del agente — no el historial del chat. Redacta el mensaje-objetivo de forma que alguien sin el contexto de la conversación entienda cómo es el estado final. + +- Bien: "Añade tests para el módulo de exportación y haz que toda la suite pase." +- No tan bien: "Arréglalo" o "Continúa con esa idea." + +Para pequeños ajustes contextuales no necesitas un objetivo — envía un mensaje normal. + +## Cómo funciona + +Cuando el agente se detiene y la sesión queda en silencio un momento, OpenChamber: + +1. Pide a un modelo pequeño y barato que audite el último turno contra el objetivo: ¿seguir, hecho o atascado? +2. Si el veredicto es "seguir", envía un prompt de continuación y el agente retoma el trabajo. +3. Si el objetivo se ha logrado de forma verificable, el objetivo se completa y recibes una notificación. +4. Si el agente está realmente atascado (necesita tu intervención), el objetivo se detiene como bloqueado — pero solo después de que el auditor lo diga tres veces seguidas, así que un tropiezo puntual nunca termina el objetivo. + +También hay topes de seguridad: un presupuesto de tokens opcional, un límite de continuaciones automáticas y una parada ante errores de turno. Si el contexto de la sesión se compacta a mitad del trabajo, el objetivo simplemente continúa — chocar con la ventana de contexto es prueba de que el trabajo no había terminado. + +### Detener y reanudar + +- El **botón de detener** aborta el turno en curso y pausa el objetivo — tu "para" explícito siempre gana al bucle. +- **Pausar** en la franja del objetivo hace lo mismo desde el otro lado: pausa el objetivo y detiene el turno en curso. +- Mientras está pausado, chatea con normalidad — el bucle no interfiere. +- **Reanudar** rearma el bucle: en una sesión inactiva el empujón de continuación sale de inmediato; si el agente está trabajando, el bucle se reengancha en su siguiente pausa. + +## Observar y gestionar + +- La franja sobre el compositor muestra la última nota de progreso, el estado y el uso de tokens, con un botón de pausar/reanudar integrado. Cuando el agente se ha detenido y el objetivo sigue activo, la franja muestra un **Evaluando…** giratorio — es la ventana de silencio y la auditoría en marcha. +- El botón de diana permanece encendido mientras el objetivo corre (azul), se vuelve verde al completarse y rojo cuando está bloqueado o sin presupuesto. Púlsalo para abrir el diálogo del objetivo: edita el objetivo o el presupuesto, o elimínalo. Un objetivo completado es de solo lectura — elimínalo y arma uno nuevo. +- En la barra lateral de sesiones aparece una pequeña diana junto a la fecha de la sesión, coloreada según el estado del objetivo. + +## Notificaciones + +Mientras un objetivo está activo, las notificaciones por turno de "agente listo" se suprimen — solo harían eco de las continuaciones del propio bucle. Cuando el objetivo se resuelve (completado, bloqueado o presupuesto alcanzado) recibes una única notificación final, en el escritorio y como push móvil. Obedece el mismo ajuste de "notificar al completar"; las solicitudes de permisos, las preguntas y las notificaciones de error siguen funcionando con normalidad. + +## Presupuesto de tokens + +En **Ajustes → Chat → Objetivo** puedes definir un presupuesto de tokens predeterminado para nuevos objetivos. Al alcanzarlo, el objetivo se detiene como "presupuesto alcanzado" en lugar de gastar más — puedes subir el presupuesto y reanudar desde el diálogo del objetivo. + +## Ten en cuenta + +- El bucle del objetivo corre en el servidor de OpenChamber, no en tu pestaña del navegador. Cierra la pestaña, bloquea el teléfono — el agente sigue trabajando y recibirás una notificación cuando el objetivo termine. El servidor (app de escritorio o proceso `openchamber`) debe seguir en marcha. +- Los objetivos usan el proveedor y modelo de tu propia sesión, incluidas las llamadas del auditor — nada sale hacia proveedores que no uses ya. +- Un objetivo por sesión a la vez. + +## Relacionado + +- [Tareas programadas](/scheduled-tasks/) — ejecutar un prompt según un horario; activa allí "Ejecutar como objetivo" para que la ejecución programada persiga su prompt hasta completarlo +- [Notificaciones](/notifications/) — cómo te enteras de un objetivo terminado diff --git a/packages/docs/content/docs/fr/scheduled-tasks.mdx b/packages/docs/content/docs/fr/scheduled-tasks.mdx index 765141dd9e..379a3d0fa3 100644 --- a/packages/docs/content/docs/fr/scheduled-tasks.mdx +++ b/packages/docs/content/docs/fr/scheduled-tasks.mdx @@ -20,6 +20,8 @@ Une tâche planifiée lance un prompt pour vous selon un planning — par exempl Vous pouvez lancer n’importe quelle tâche immédiatement avec **run now** pour vérifier qu’elle fait ce que vous attendez. +Cochez **Exécuter comme objectif** pour que l'exécution poursuive son prompt jusqu'au bout au lieu de s'arrêter après une réponse — voir [Objectifs de session](/session-goals/). + ## À quoi ressemble une réussite Après une exécution, la tâche indique quand elle a tourné pour la dernière fois, si elle a réussi et un lien vers la session créée. Si une exécution échoue, l’erreur s’affiche aussi à cet endroit. diff --git a/packages/docs/content/docs/fr/session-goals.mdx b/packages/docs/content/docs/fr/session-goals.mdx new file mode 100644 index 0000000000..74816bca61 --- /dev/null +++ b/packages/docs/content/docs/fr/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Objectifs de session +description: Transformez un prompt en objectif vers lequel l'agent travaille automatiquement. +--- + +# Objectifs de session + +Un objectif transforme un seul prompt en ligne d'arrivée. Au lieu de relancer l'agent avec « continue » après chaque réponse, vous définissez l'objectif une fois — et OpenChamber fait travailler la session vers lui automatiquement, en vérifiant la progression avec un auditeur indépendant après chaque tour. Le travail continue même en votre absence. + +## Démarrer un objectif + +1. Appuyez sur le bouton cible du composeur. Il s'allume — le mode objectif est armé. +2. Écrivez votre prompt et envoyez-le. Ce message devient l'objectif. + +Cela fonctionne aussi bien dans une session existante que dans un brouillon de nouvelle session : armez la cible, écrivez le premier message, envoyez — la nouvelle session démarre avec l'objectif déjà actif. + +### D'autres façons de démarrer un objectif + +- **Depuis une réponse de l'agent** : dans le dialogue « Start new session from this answer », cochez **Exécuter comme objectif** — la réponse est transmise comme une mission que la nouvelle session exécute jusqu'au bout (combinez avec **Create worktree** pour une exécution isolée). +- **Depuis un plan** : en implémentant un plan enregistré dans une nouvelle session ou un worktree, cochez **Exécuter comme objectif** dans le dialogue. L'objectif porte le contenu du plan, l'auditeur juge donc la progression par rapport au plan réel. +- **Selon un horaire** : cochez **Exécuter comme objectif** sur une [tâche planifiée](/scheduled-tasks/) pour que les exécutions récurrentes poursuivent leur prompt jusqu'au bout. + +## Rédigez un objectif autonome + +L'auditeur de progression ne voit que votre objectif et la dernière réponse de l'agent — pas l'historique du chat. Formulez donc le message-objectif de sorte qu'une personne sans le contexte de la conversation comprenne à quoi ressemble l'état final. + +- Bien : « Ajoute des tests pour le module d'export et fais passer toute la suite de tests. » +- Moins bien : « Corrige ça » ou « Continue sur cette idée. » + +Pour de petits ajustements contextuels, pas besoin d'objectif — envoyez un message normal. + +## Comment ça marche + +Quand l'agent s'arrête et que la session reste calme un instant, OpenChamber : + +1. Demande à un petit modèle économique d'auditer le dernier tour par rapport à l'objectif : continuer, terminé ou bloqué ? +2. Si le verdict est « continuer », il envoie un prompt de continuation et l'agent reprend le travail. +3. Si l'objectif est atteint de manière vérifiable, l'objectif se termine et vous recevez une notification. +4. Si l'agent est réellement bloqué (il a besoin de vous), l'objectif s'arrête comme bloqué — mais seulement après que l'auditeur l'a dit trois fois de suite ; un accroc ponctuel ne termine jamais l'objectif. + +Il y a aussi des garde-fous : un budget de tokens optionnel, un plafond de continuations automatiques et un arrêt en cas d'erreur de tour. Si le contexte de la session est compacté en plein travail, l'objectif continue simplement — heurter la fenêtre de contexte prouve que le travail n'était pas fini. + +### Arrêter et reprendre + +- Le **bouton stop** interrompt le tour en cours et met l'objectif en pause — votre « stop » explicite l'emporte toujours sur la boucle. +- **Pause** sur la bande de l'objectif fait la même chose dans l'autre sens : elle met l'objectif en pause et arrête le tour en cours. +- Pendant la pause, discutez normalement — la boucle reste à l'écart. +- **Reprendre** réarme la boucle : sur une session inactive, la relance part immédiatement ; si l'agent est en train de travailler, la boucle se raccroche silencieusement à sa prochaine pause. + +## Suivre et gérer + +- La bande au-dessus du composeur affiche la dernière note de progression, le statut et l'usage de tokens, avec un bouton pause/reprise intégré. Quand l'agent s'est arrêté et que l'objectif est actif, la bande affiche un **Évaluation…** animé — c'est la fenêtre de calme et l'audit en cours. +- Le bouton cible reste allumé tant que l'objectif tourne (bleu), passe au vert à la fin et au rouge s'il est bloqué ou à court de budget. Appuyez dessus pour ouvrir le dialogue de l'objectif : modifier l'objectif ou le budget, ou le supprimer. Un objectif terminé est en lecture seule — supprimez-le, puis armez-en un nouveau. +- Dans la barre latérale des sessions, une petite cible apparaît à côté de la date de la session, colorée selon l'état de l'objectif. + +## Notifications + +Tant qu'un objectif est actif, les notifications « agent prêt » à chaque tour sont supprimées — elles ne feraient qu'écho aux continuations de la boucle elle-même. Quand l'objectif se règle (terminé, bloqué ou budget atteint), vous recevez une seule notification finale, sur le bureau et en push mobile. Elle respecte le même réglage « notifier à la fin » ; les demandes de permission, les questions et les notifications d'erreur continuent de fonctionner normalement. + +## Budget de tokens + +Dans **Paramètres → Chat → Objectif**, vous pouvez définir un budget de tokens par défaut pour les nouveaux objectifs. Quand un objectif atteint son budget, il s'arrête en « budget atteint » au lieu de dépenser plus — vous pouvez augmenter le budget et reprendre depuis le dialogue de l'objectif. + +## À garder en tête + +- La boucle d'objectif tourne dans le serveur OpenChamber, pas dans votre onglet de navigateur. Fermez l'onglet, verrouillez le téléphone — l'agent continue, et vous recevez une notification quand l'objectif se termine. Le serveur (app de bureau ou processus `openchamber`) doit rester lancé. +- Les objectifs utilisent le fournisseur et le modèle de votre propre session, y compris pour les appels de l'auditeur — rien ne part vers des fournisseurs que vous n'utilisez pas déjà. +- Un objectif par session à la fois. + +## Voir aussi + +- [Tâches planifiées](/scheduled-tasks/) — exécuter un prompt selon un horaire ; activez-y « Exécuter comme objectif » pour qu'une exécution planifiée poursuive son prompt jusqu'au bout +- [Notifications](/notifications/) — comment vous êtes prévenu d'un objectif terminé diff --git a/packages/docs/content/docs/ja/scheduled-tasks.mdx b/packages/docs/content/docs/ja/scheduled-tasks.mdx index a9b5301cb8..f6c7d6d31d 100644 --- a/packages/docs/content/docs/ja/scheduled-tasks.mdx +++ b/packages/docs/content/docs/ja/scheduled-tasks.mdx @@ -20,6 +20,8 @@ description: プロンプトをスケジュールに従って自動実行しま 任意のタスクは **run now** ですぐ実行でき、期待通り動くか確認できます。 +**ゴールとして実行**にチェックすると、実行は1回の返信で止まらず、プロンプトを完了まで追求します — [セッションゴール](/session-goals/)を参照してください。 + ## 成功時の見え方 実行後、タスクには最後に実行された時刻、成功したかどうか、作成されたセッションへのリンクが表示されます。実行に失敗した場合は、エラーもそこに表示されます。 diff --git a/packages/docs/content/docs/ja/session-goals.mdx b/packages/docs/content/docs/ja/session-goals.mdx new file mode 100644 index 0000000000..329bdbd478 --- /dev/null +++ b/packages/docs/content/docs/ja/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: セッションゴール +description: プロンプトをゴールに変え、エージェントが自動的に取り組み続けます。 +--- + +# セッションゴール + +ゴールは、1つのプロンプトをゴールラインに変えます。返信のたびに「続けて」と促す代わりに、ゴールを一度設定するだけ — OpenChamber がセッションを自動的にゴールへ向かわせ、各ターンの後に独立した監査モデルで進捗を確認します。離席中でも動き続けます。 + +## ゴールを開始する + +1. コンポーザーのターゲットボタンを押します。点灯すればゴールモードが準備完了です。 +2. プロンプトを書いて送信します。そのメッセージがゴールの目標になります。 + +既存のセッションでも新規セッションの下書きでも同じように機能します。ターゲットを有効にして最初のメッセージを書いて送信すれば、新しいセッションは最初からゴールが有効な状態で始まります。 + +### ゴールを開始する他の方法 + +- **エージェントの返信から**:「Start new session from this answer」ダイアログで **ゴールとして実行** にチェック — 返信が課題として引き継がれ、新しいセッションが完了まで実行します(**Create worktree** と組み合わせれば隔離された実行になります)。 +- **プランから**:保存したプランを新しいセッションや worktree で実装するとき、ダイアログの **ゴールとして実行** にチェック。ゴールの目標にはプランの内容が入るため、監査はプランそのものに照らして進捗を判定します。 +- **スケジュールで**:[スケジュールタスク](/scheduled-tasks/)の **ゴールとして実行** にチェックすると、定期実行がプロンプトを完了まで追求します。 + +## 自己完結した目標を書く + +進捗の監査モデルが見るのは、あなたの目標とエージェントの最新の返信だけです — チャット履歴は見ません。会話の文脈を知らない人でも完成状態がわかるように、ゴールのメッセージを書いてください。 + +- 良い例:「エクスポートモジュールのテストを追加し、テストスイート全体を通るようにして。」 +- 良くない例:「直して」「さっきのアイデアで続けて」 + +ちょっとした文脈依存の指示にはゴールは不要です — 普通のメッセージを送りましょう。 + +## 仕組み + +エージェントが停止してセッションがしばらく静かになると、OpenChamber は: + +1. 小型で安価なモデルに、最新のターンを目標と照らして監査させます:続行、完了、それとも行き詰まり? +2. 判定が「続行」なら、継続プロンプトを送り、エージェントが作業を再開します。 +3. 目標が検証可能な形で達成されていればゴールは完了し、通知が届きます。 +4. エージェントが本当に行き詰まっている(あなたの入力が必要な)場合、ゴールはブロックとして停止します — ただし監査が3回連続でそう判定した場合のみ。一度のつまずきでゴールが終わることはありません。 + +ハードな安全装置もあります:オプションのトークン予算、自動継続の上限、ターンエラー時の停止です。作業中にセッションのコンテキストが圧縮されても、ゴールはそのまま続行します — コンテキストウィンドウに達したこと自体が、作業が終わっていない証拠だからです。 + +### 停止と再開 + +- **停止ボタン**は実行中のターンを中断し、ゴールを一時停止します — あなたの明示的な「止めて」は常にループより優先されます。 +- ストリップの**一時停止**は逆方向から同じことをします:ゴールを一時停止し、実行中のターンを止めます。 +- 一時停止中は普通にチャットできます — ループは邪魔をしません。 +- **再開**はループを再始動します:アイドルなセッションでは継続プロンプトが即座に送られ、エージェントが作業中なら次の停止時にループが静かに再接続します。 + +## 確認と管理 + +- コンポーザー上部のストリップに、最新の進捗メモ、ステータス、トークン使用量が表示され、一時停止/再開ボタンも組み込まれています。エージェントが停止していてゴールがアクティブなときは、回転する**評価中…**が表示されます — 静止ウィンドウと監査が動いている印です。 +- ターゲットボタンはゴール実行中は点灯し(青)、完了で緑、ブロックや予算切れで赤になります。押すとゴールのダイアログが開き、目標や予算の編集、ゴールの削除ができます。完了したゴールは読み取り専用です — 削除してから新しいゴールを開始してください。 +- セッションサイドバーでは、セッションの日付の横にゴールの状態色の小さなターゲットが表示されます。 + +## 通知 + +ゴールがアクティブな間、ターンごとの「エージェント準備完了」通知は抑制されます — ループ自身の継続をなぞるだけだからです。ゴールが確定すると(完了、ブロック、予算到達)、代わりに最終通知が1件、デスクトップとモバイルプッシュで届きます。「完了時に通知」と同じ設定に従います。権限リクエスト、質問、エラー通知は通常どおり機能し続けます。 + +## トークン予算 + +**設定 → チャット → ゴール** で、新しいゴールのデフォルトトークン予算を設定できます。予算に達するとゴールは「予算上限に到達」として停止し、それ以上消費しません — 予算を上げてゴールのダイアログから再開できます。 + +## 留意点 + +- ゴールのループはブラウザのタブではなく OpenChamber サーバーで動きます。タブを閉じても、スマホをロックしても — エージェントは働き続け、ゴールが確定すると通知が届きます。サーバー(デスクトップアプリまたは `openchamber` プロセス)は起動したままにしてください。 +- ゴールはセッション自身のプロバイダーとモデルを使います。監査の呼び出しも同様です — 既に使っているプロバイダーの外にデータが出ることはありません。 +- ゴールは1セッションにつき同時に1つです。 + +## 関連 + +- [スケジュールタスク](/scheduled-tasks/) — スケジュールでプロンプトを実行。「ゴールとして実行」を有効にすると、スケジュール実行がプロンプトを完了まで追求します +- [通知](/notifications/) — 完了したゴールを知る方法 diff --git a/packages/docs/content/docs/ko/scheduled-tasks.mdx b/packages/docs/content/docs/ko/scheduled-tasks.mdx index aef273e9d7..f42e64cfc8 100644 --- a/packages/docs/content/docs/ko/scheduled-tasks.mdx +++ b/packages/docs/content/docs/ko/scheduled-tasks.mdx @@ -20,6 +20,8 @@ description: 일정에 따라 프롬프트를 자동으로 실행하세요. **run now**로 작업을 즉시 실행하여 기대대로 동작하는지 확인할 수 있습니다. +**목표로 실행**을 체크하면 실행이 한 번의 답변에서 멈추지 않고 프롬프트를 완료까지 추진합니다 — [세션 목표](/session-goals/)를 참고하세요. + ## 성공이란 어떤 모습인가 실행 후 작업에는 마지막 실행 시각, 성공 여부, 생성된 세션으로의 링크가 표시됩니다. 실행이 실패하면 오류도 거기에 표시됩니다. diff --git a/packages/docs/content/docs/ko/session-goals.mdx b/packages/docs/content/docs/ko/session-goals.mdx new file mode 100644 index 0000000000..aa90ff473e --- /dev/null +++ b/packages/docs/content/docs/ko/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: 세션 목표 +description: 프롬프트를 목표로 바꾸면 에이전트가 자동으로 계속 작업합니다. +--- + +# 세션 목표 + +목표는 하나의 프롬프트를 결승선으로 바꿉니다. 답변이 올 때마다 "계속해"라고 재촉하는 대신 목표를 한 번만 설정하면 — OpenChamber가 세션을 자동으로 목표를 향해 이끌고, 매 턴이 끝날 때마다 독립적인 감사 모델로 진행 상황을 확인합니다. 자리를 비운 동안에도 계속 작동합니다. + +## 목표 시작하기 + +1. 컴포저의 타깃 버튼을 누릅니다. 불이 켜지면 목표 모드가 준비된 것입니다. +2. 프롬프트를 작성하고 전송합니다. 그 메시지가 목표가 됩니다. + +기존 세션과 새 세션 초안 모두에서 동일하게 작동합니다. 타깃을 켜고 첫 메시지를 작성해 전송하면 — 새 세션이 목표가 이미 활성화된 상태로 시작됩니다. + +### 목표를 시작하는 다른 방법 + +- **에이전트의 답변에서**: "Start new session from this answer" 대화 상자에서 **목표로 실행**을 체크 — 답변이 과제로 넘겨져 새 세션이 완료까지 실행합니다(**Create worktree**와 결합하면 격리된 실행이 됩니다). +- **계획에서**: 저장된 계획을 새 세션이나 worktree에서 구현할 때 대화 상자의 **목표로 실행**을 체크하세요. 목표에 계획 내용이 담기므로 감사가 실제 계획을 기준으로 진행 상황을 판단합니다. +- **일정에 따라**: [예약 작업](/scheduled-tasks/)에서 **목표로 실행**을 체크하면 반복 실행이 프롬프트를 완료까지 추진합니다. + +## 자기 완결적인 목표 작성하기 + +진행 감사 모델은 목표와 에이전트의 최신 답변만 봅니다 — 채팅 기록은 보지 않습니다. 따라서 대화 맥락을 모르는 사람도 완료 상태가 어떤 모습인지 이해할 수 있도록 목표 메시지를 작성하세요. + +- 좋은 예: "내보내기 모듈에 테스트를 추가하고 전체 테스트 스위트를 통과시켜." +- 좋지 않은 예: "고쳐줘" 또는 "그 아이디어로 계속해." + +작은 맥락적 후속 지시에는 목표가 필요 없습니다 — 그냥 일반 메시지를 보내세요. + +## 작동 방식 + +에이전트가 멈추고 세션이 잠시 조용해지면 OpenChamber는: + +1. 작고 저렴한 모델에게 최신 턴을 목표와 대조해 감사하게 합니다: 계속, 완료, 아니면 막힘? +2. 판정이 "계속"이면 계속 프롬프트를 보내고 에이전트가 작업을 다시 시작합니다. +3. 목표가 검증 가능하게 달성되면 목표가 완료되고 알림이 옵니다. +4. 에이전트가 정말로 막혔다면(사용자의 입력이 필요하면) 목표는 차단됨으로 멈춥니다 — 단, 감사가 세 번 연속 그렇게 판정한 후에만요. 한 번의 걸림돌로 목표가 끝나는 일은 없습니다. + +강제 안전장치도 있습니다: 선택적 토큰 예산, 자동 계속 횟수 상한, 턴 오류 시 중지. 작업 도중 세션 컨텍스트가 압축되어도 목표는 그냥 계속됩니다 — 컨텍스트 창에 부딪혔다는 것 자체가 작업이 끝나지 않았다는 증거이기 때문입니다. + +### 중지와 재개 + +- **중지 버튼**은 실행 중인 턴을 중단하고 목표를 일시 중지합니다 — 당신의 명시적인 "멈춰"가 항상 루프보다 우선합니다. +- 스트립의 **일시 중지**는 반대 방향에서 같은 일을 합니다: 목표를 일시 중지하고 실행 중인 턴을 멈춥니다. +- 일시 중지 중에는 평소처럼 채팅하세요 — 루프가 끼어들지 않습니다. +- **재개**는 루프를 다시 켭니다: 유휴 세션에서는 계속 프롬프트가 즉시 나가고, 에이전트가 작업 중이라면 다음 멈춤에서 루프가 조용히 다시 붙습니다. + +## 확인 및 관리 + +- 컴포저 위의 스트립에 최신 진행 메모, 상태, 토큰 사용량이 표시되며, 일시 중지/재개 버튼이 함께 있습니다. 에이전트가 멈췄는데 목표가 활성 상태라면 스트립에 회전하는 **평가 중…**이 표시됩니다 — 조용한 대기 시간과 감사가 진행 중이라는 뜻입니다. +- 타깃 버튼은 목표가 실행 중일 때 켜져 있고(파란색), 완료되면 초록색, 차단되거나 예산이 소진되면 빨간색이 됩니다. 누르면 목표 대화 상자가 열립니다: 목표나 예산을 편집하거나 목표를 제거하세요. 완료된 목표는 읽기 전용입니다 — 제거한 후 새 목표를 시작하세요. +- 세션 사이드바에서 세션 날짜 옆에 목표 상태 색상의 작은 타깃이 나타납니다. + +## 알림 + +목표가 활성 상태인 동안 턴마다 오는 "에이전트 준비 완료" 알림은 억제됩니다 — 루프 자체의 계속 실행을 되풀이할 뿐이기 때문입니다. 목표가 확정되면(완료, 차단, 예산 도달) 대신 최종 알림 하나가 데스크톱과 모바일 푸시로 옵니다. "완료 시 알림"과 같은 설정을 따릅니다. 권한 요청, 질문, 오류 알림은 평소처럼 계속 작동합니다. + +## 토큰 예산 + +**설정 → 채팅 → 목표**에서 새 목표의 기본 토큰 예산을 설정할 수 있습니다. 목표가 예산에 도달하면 더 소비하지 않고 "예산 도달"로 멈춥니다 — 예산을 올리고 목표 대화 상자에서 재개할 수 있습니다. + +## 유의 사항 + +- 목표 루프는 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 탭을 닫든 휴대폰을 잠그든 — 에이전트는 계속 일하고, 목표가 확정되면 알림이 옵니다. 서버(데스크톱 앱 또는 `openchamber` 프로세스)는 계속 실행 중이어야 합니다. +- 목표는 감사 호출을 포함해 세션 자체의 프로바이더와 모델을 사용합니다 — 이미 사용 중인 프로바이더 밖으로 나가는 것은 없습니다. +- 세션당 목표는 한 번에 하나입니다. + +## 관련 + +- [예약 작업](/scheduled-tasks/) — 일정에 따라 프롬프트 실행; "목표로 실행"을 켜면 예약 실행이 프롬프트를 완료까지 추진합니다 +- [알림](/notifications/) — 완료된 목표를 알게 되는 방법 diff --git a/packages/docs/content/docs/pl/scheduled-tasks.mdx b/packages/docs/content/docs/pl/scheduled-tasks.mdx index 842ab5e483..8f67621cb5 100644 --- a/packages/docs/content/docs/pl/scheduled-tasks.mdx +++ b/packages/docs/content/docs/pl/scheduled-tasks.mdx @@ -20,6 +20,8 @@ Zaplanowane zadanie uruchamia za Ciebie prompt według harmonogramu — na przyk Dowolne zadanie możesz uruchomić natychmiast za pomocą **run now**, aby sprawdzić, czy robi to, czego oczekujesz. +Zaznacz **Uruchom jako cel**, aby uruchomienie doprowadziło prompt do końca zamiast zatrzymywać się po jednej odpowiedzi — zobacz [Cele sesji](/session-goals/). + ## Jak wygląda sukces Po uruchomieniu zadanie pokazuje, kiedy ostatnio się wykonało, czy się powiodło, oraz link do utworzonej sesji. Jeśli uruchomienie się nie powiedzie, błąd również jest tam pokazany. diff --git a/packages/docs/content/docs/pl/session-goals.mdx b/packages/docs/content/docs/pl/session-goals.mdx new file mode 100644 index 0000000000..c1ad1fdd66 --- /dev/null +++ b/packages/docs/content/docs/pl/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Cele sesji +description: Zamień prompt w cel, nad którym agent pracuje automatycznie. +--- + +# Cele sesji + +Cel zamienia jeden prompt w linię mety. Zamiast popychać agenta słowem „kontynuuj" po każdej odpowiedzi, ustawiasz cel raz — a OpenChamber automatycznie prowadzi sesję w jego stronę, sprawdzając postęp niezależnym audytorem po każdej turze. Praca trwa nawet pod twoją nieobecność. + +## Rozpoczęcie celu + +1. Naciśnij przycisk celu (tarczę) w kompozytorze. Zapala się — tryb celu jest uzbrojony. +2. Napisz prompt i wyślij. Ta wiadomość staje się treścią celu. + +Działa to tak samo w istniejącej sesji i w szkicu nowej: uzbrój tarczę, napisz pierwszą wiadomość, wyślij — nowa sesja startuje z już aktywnym celem. + +### Inne sposoby rozpoczęcia celu + +- **Z odpowiedzi agenta**: w oknie „Start new session from this answer" zaznacz **Uruchom jako cel** — odpowiedź zostaje przekazana jako zadanie, które nowa sesja wykonuje do końca (połącz z **Create worktree**, aby uzyskać izolowane uruchomienie). +- **Z planu**: implementując zapisany plan w nowej sesji lub worktree, zaznacz **Uruchom jako cel** w oknie dialogowym. Treścią celu staje się zawartość planu, więc audytor ocenia postęp względem samego planu. +- **Według harmonogramu**: zaznacz **Uruchom jako cel** w [zaplanowanym zadaniu](/scheduled-tasks/), aby cykliczne uruchomienia doprowadzały prompt do końca. + +## Formułuj cel samowystarczalnie + +Audytor postępu widzi tylko treść celu i ostatnią odpowiedź agenta — bez historii czatu. Sformułuj więc wiadomość-cel tak, aby osoba bez kontekstu rozmowy zrozumiała, jak wygląda stan końcowy. + +- Dobrze: „Dodaj testy dla modułu eksportu i doprowadź cały zestaw testów do zielonego stanu." +- Słabiej: „Napraw to" albo „Kontynuuj z tamtym pomysłem." + +Do drobnych kontekstowych poleceń cel nie jest potrzebny — wyślij zwykłą wiadomość. + +## Jak to działa + +Gdy agent się zatrzyma i sesja na chwilę ucichnie, OpenChamber: + +1. Prosi mały, tani model o audyt ostatniej tury względem celu: kontynuować, gotowe czy utknięto? +2. Jeśli werdykt to „kontynuować", wysyła prompt kontynuacji i agent wraca do pracy. +3. Jeśli cel jest weryfikowalnie osiągnięty, cel się kończy, a ty dostajesz powiadomienie. +4. Jeśli agent naprawdę utknął (potrzebuje twojego udziału), cel zatrzymuje się jako zablokowany — ale dopiero gdy audytor powie to trzy razy z rzędu, więc jednorazowa przeszkoda nigdy nie kończy celu. + +Są też twarde bezpieczniki: opcjonalny budżet tokenów, limit automatycznych kontynuacji i stop przy błędzie tury. Jeśli kontekst sesji zostanie skompaktowany w trakcie pracy, cel po prostu trwa dalej — uderzenie w okno kontekstu to dowód, że praca nie była skończona. + +### Zatrzymywanie i wznawianie + +- **Przycisk stop** przerywa bieżącą turę i wstrzymuje cel — twoje wyraźne „stop" zawsze wygrywa z pętlą. +- **Wstrzymaj** na pasku celu robi to samo z drugiej strony: wstrzymuje cel i zatrzymuje bieżącą turę. +- Podczas wstrzymania rozmawiaj normalnie — pętla nie przeszkadza. +- **Wznów** ponownie uzbraja pętlę: w bezczynnej sesji zachęta do kontynuacji wychodzi natychmiast; jeśli agent akurat pracuje, pętla po cichu podłącza się przy jego następnej przerwie. + +## Podgląd i zarządzanie + +- Pasek nad kompozytorem pokazuje ostatnią notatkę postępu, status i zużycie tokenów, wraz z przyciskiem wstrzymaj/wznów. Gdy agent się zatrzymał, a cel jest aktywny, pasek pokazuje wirujące **Ocenianie…** — to okno ciszy i trwający audyt. +- Przycisk-tarcza świeci, póki cel działa (niebieski), zielenieje po ukończeniu, a czerwienieje przy zablokowaniu lub wyczerpaniu budżetu. Naciśnij go, aby otworzyć okno celu: edytuj treść lub budżet albo usuń cel. Ukończony cel jest tylko do odczytu — usuń go, a potem uzbrój nowy. +- W panelu bocznym sesji obok daty sesji pojawia się mała tarcza w kolorze stanu celu. + +## Powiadomienia + +Póki cel jest aktywny, powiadomienia „agent gotowy" po każdej turze są wyciszone — powtarzałyby tylko kontynuacje samej pętli. Gdy cel się rozstrzygnie (ukończony, zablokowany lub budżet wyczerpany), dostajesz zamiast tego jedno końcowe powiadomienie — na desktopie i jako push mobilny. Respektuje to samo ustawienie „powiadamiaj o ukończeniu"; prośby o uprawnienia, pytania i powiadomienia o błędach działają jak zwykle. + +## Budżet tokenów + +W **Ustawienia → Czat → Cel** możesz ustawić domyślny budżet tokenów dla nowych celów. Po osiągnięciu budżetu cel zatrzymuje się jako „budżet wyczerpany" zamiast wydawać więcej — możesz podnieść budżet i wznowić z okna celu. + +## Miej na uwadze + +- Pętla celu działa na serwerze OpenChamber, nie w karcie przeglądarki. Zamknij kartę, zablokuj telefon — agent pracuje dalej, a gdy cel się rozstrzygnie, dostaniesz powiadomienie. Serwer (aplikacja desktopowa lub proces `openchamber`) musi pozostać uruchomiony. +- Cele używają dostawcy i modelu twojej własnej sesji, łącznie z wywołaniami audytora — nic nie trafia do dostawców, których już nie używasz. +- Jeden cel na sesję naraz. + +## Powiązane + +- [Zaplanowane zadania](/scheduled-tasks/) — uruchamianie promptu według harmonogramu; włącz tam „Uruchom jako cel", aby zaplanowane uruchomienie doprowadziło prompt do końca +- [Powiadomienia](/notifications/) — jak dowiadujesz się o ukończonym celu diff --git a/packages/docs/content/docs/pt-br/scheduled-tasks.mdx b/packages/docs/content/docs/pt-br/scheduled-tasks.mdx index 2d50f2f61d..4332ee1aee 100644 --- a/packages/docs/content/docs/pt-br/scheduled-tasks.mdx +++ b/packages/docs/content/docs/pt-br/scheduled-tasks.mdx @@ -20,6 +20,8 @@ Uma tarefa agendada executa um prompt para você em uma agenda — por exemplo, Você pode executar qualquer tarefa imediatamente com **run now** para verificar se ela faz o que você espera. +Marque **Executar como objetivo** para que a execução persiga o prompt até concluir em vez de parar após uma resposta — veja [Objetivos de sessão](/session-goals/). + ## Como é o sucesso Após uma execução, a tarefa mostra quando rodou pela última vez, se teve êxito e um link para a sessão que ela criou. Se uma execução falha, o erro também é mostrado ali. diff --git a/packages/docs/content/docs/pt-br/session-goals.mdx b/packages/docs/content/docs/pt-br/session-goals.mdx new file mode 100644 index 0000000000..50f48783a5 --- /dev/null +++ b/packages/docs/content/docs/pt-br/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Objetivos de sessão +description: Transforme um prompt em um objetivo no qual o agente trabalha automaticamente. +--- + +# Objetivos de sessão + +Um objetivo transforma um único prompt em uma linha de chegada. Em vez de cutucar o agente com "continua" após cada resposta, você define o objetivo uma vez — e o OpenChamber mantém a sessão trabalhando em direção a ele automaticamente, verificando o progresso com um auditor independente após cada turno. Continua rodando mesmo enquanto você está ausente. + +## Iniciar um objetivo + +1. Pressione o botão de alvo no compositor. Ele acende — o modo objetivo está armado. +2. Escreva seu prompt e envie. Essa mensagem se torna o objetivo. + +Funciona igualmente em uma sessão existente e em um rascunho de sessão nova: arme o alvo, escreva a primeira mensagem, envie — a nova sessão começa com o objetivo já ativo. + +### Mais formas de iniciar um objetivo + +- **A partir de uma resposta do agente**: no diálogo "Start new session from this answer", marque **Executar como objetivo** — a resposta é entregue como uma tarefa que a nova sessão executa até concluir (combine com **Create worktree** para uma execução isolada). +- **A partir de um plano**: ao implementar um plano salvo em uma sessão ou worktree novos, marque **Executar como objetivo** no diálogo. O objetivo carrega o conteúdo do plano, então o auditor julga o progresso contra o plano real. +- **Em um cronograma**: marque **Executar como objetivo** em uma [tarefa agendada](/scheduled-tasks/) para que execuções recorrentes persigam o prompt até concluir. + +## Escreva um objetivo autocontido + +O auditor de progresso vê apenas o seu objetivo e a última resposta do agente — não o histórico do chat. Então formule a mensagem-objetivo de forma que alguém sem o contexto da conversa entenda como é o estado final. + +- Bom: "Adicione testes para o módulo de exportação e faça toda a suíte de testes passar." +- Nem tanto: "Conserta isso" ou "Continua com aquela ideia." + +Para pequenos ajustes contextuais você não precisa de um objetivo — envie uma mensagem normal. + +## Como funciona + +Quando o agente para e a sessão fica quieta por um momento, o OpenChamber: + +1. Pede a um modelo pequeno e barato que audite o último turno contra o objetivo: continuar, pronto ou travado? +2. Se o veredicto for "continuar", envia um prompt de continuação e o agente retoma o trabalho. +3. Se o objetivo foi alcançado de forma verificável, o objetivo é concluído e você recebe uma notificação. +4. Se o agente está realmente travado (precisa da sua participação), o objetivo para como bloqueado — mas só depois que o auditor disser isso três vezes seguidas, então um tropeço pontual nunca encerra o objetivo. + +Há também freios de segurança: um orçamento de tokens opcional, um teto de continuações automáticas e parada em erros de turno. Se o contexto da sessão for compactado no meio do trabalho, o objetivo simplesmente continua — bater na janela de contexto é prova de que o trabalho não tinha terminado. + +### Parar e retomar + +- O **botão de parar** aborta o turno em andamento e pausa o objetivo — o seu "para" explícito sempre vence o loop. +- **Pausar** na faixa do objetivo faz o mesmo pelo outro lado: pausa o objetivo e para o turno em andamento. +- Enquanto pausado, converse normalmente — o loop fica de fora. +- **Retomar** rearma o loop: em uma sessão ociosa o empurrão de continuação sai imediatamente; se o agente estiver trabalhando, o loop se reconecta silenciosamente na próxima pausa dele. + +## Acompanhar e gerenciar + +- A faixa acima do compositor mostra a última nota de progresso, o status e o uso de tokens, com um botão de pausar/retomar integrado. Quando o agente parou e o objetivo está ativo, a faixa mostra um **Avaliando…** girando — é a janela de silêncio e a auditoria em andamento. +- O botão de alvo fica aceso enquanto o objetivo roda (azul), fica verde ao concluir e vermelho quando bloqueado ou sem orçamento. Pressione-o para abrir o diálogo do objetivo: edite o objetivo ou o orçamento, ou remova-o. Um objetivo concluído é somente leitura — remova-o e arme um novo. +- Na barra lateral de sessões, um pequeno alvo aparece ao lado da data da sessão, colorido pelo estado do objetivo. + +## Notificações + +Enquanto um objetivo está ativo, as notificações por turno de "agente pronto" são suprimidas — elas só ecoariam as continuações do próprio loop. Quando o objetivo se resolve (concluído, bloqueado ou orçamento atingido) você recebe uma única notificação final, no desktop e como push móvel. Ela obedece à mesma configuração de "notificar ao concluir"; solicitações de permissão, perguntas e notificações de erro continuam funcionando normalmente. + +## Orçamento de tokens + +Em **Configurações → Chat → Objetivo** você pode definir um orçamento de tokens padrão para novos objetivos. Quando um objetivo atinge o orçamento, ele para como "orçamento atingido" em vez de gastar mais — você pode aumentar o orçamento e retomar pelo diálogo do objetivo. + +## Tenha em mente + +- O loop do objetivo roda no servidor do OpenChamber, não na aba do navegador. Feche a aba, bloqueie o celular — o agente continua trabalhando, e você recebe uma notificação quando o objetivo se resolve. O servidor (app desktop ou processo `openchamber`) precisa continuar rodando. +- Objetivos usam o provedor e o modelo da sua própria sessão, incluindo as chamadas do auditor — nada sai para provedores que você já não use. +- Um objetivo por sessão de cada vez. + +## Relacionado + +- [Tarefas agendadas](/scheduled-tasks/) — rodar um prompt em um cronograma; ative lá "Executar como objetivo" para que a execução agendada persiga o prompt até concluir +- [Notificações](/notifications/) — como você fica sabendo de um objetivo concluído diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx index fe4bb48ad7..e775319050 100644 --- a/packages/docs/content/docs/scheduled-tasks.mdx +++ b/packages/docs/content/docs/scheduled-tasks.mdx @@ -20,6 +20,8 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s You can run any task immediately with **run now** to check it does what you expect. +Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/). + ## What success looks like After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too. diff --git a/packages/docs/content/docs/session-goals.mdx b/packages/docs/content/docs/session-goals.mdx new file mode 100644 index 0000000000..0a672373d9 --- /dev/null +++ b/packages/docs/content/docs/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Session Goals +description: Turn a prompt into a goal the agent keeps working toward automatically. +--- + +# Session Goals + +A goal turns one prompt into a finish line. Instead of nudging the agent with "continue" after every reply, you set a goal once — and OpenChamber keeps the session working toward it automatically, checking progress with an independent auditor after every turn. It keeps running even while you are away. + +## Start a goal + +1. Press the target button in the composer. It lights up — goal mode is armed. +2. Type your prompt and send it. That message becomes the goal's objective. + +This works in an existing session and in a new session draft alike: arm the target, write the first message, send — the new session starts with the goal already active. + +### More ways to start a goal + +- **From an agent's reply**: in the "Start new session from this answer" dialog, check **Run as goal** — the reply is handed over as an assignment the new session executes to completion (combine with **Create worktree** for an isolated run). +- **From a plan**: when implementing a saved plan in a new session or worktree, check **Run as goal** in the dialog. The goal carries the plan content as its objective, so the auditor judges progress against the actual plan. +- **On a schedule**: check **Run as goal** on a [scheduled task](/scheduled-tasks/) to make recurring runs pursue their prompt to completion. + +## Write a self-contained objective + +The progress auditor sees only your objective and the agent's latest reply — not the chat history. So phrase the goal message so that someone without the conversation context would understand what the finished state looks like. + +- Good: "Add tests for the export module and make the whole test suite pass." +- Not so good: "Fix it" or "Continue with that idea." + +For small contextual follow-ups you don't need a goal — just send a normal message. + +## How it works + +After the agent stops and the session stays quiet for a moment, OpenChamber: + +1. Asks a small, cheap model to audit the latest turn against the objective: keep going, done, or stuck? +2. If the verdict is "keep going", it sends a continuation prompt and the agent picks the work back up. +3. If the objective is verifiably achieved, the goal completes and you get a notification. +4. If the agent is genuinely stuck (needs your input), the goal stops as blocked — but only after the auditor says so three times in a row, so a one-off snag never ends the goal. + +There are hard safety stops too: an optional token budget, a cap on automatic continuations, and a stop on turn errors. If the session's context gets compacted mid-work, the goal simply continues — running into the context window is proof the work wasn't finished. + +### Stopping and resuming + +- The **stop button** aborts the running turn and pauses the goal — your explicit "stop" always wins over the loop. +- **Pause** on the goal strip does the same from the other direction: it pauses the goal and stops the running turn. +- While paused, chat normally — the loop stays out of the way. +- **Resume** re-arms the loop: on an idle session the continuation nudge goes out immediately; if the agent happens to be working, the loop silently re-attaches at its next pause. + +## Watch and manage + +- The strip above the composer shows the goal's latest progress note, status, and token usage, with an inline pause/resume button. When the agent has stopped and the goal is active, the strip shows a spinning **Evaluating…** — that's the quiet window and the audit running. +- The target button stays lit while the goal runs (blue), turns green on completion, and red when blocked or out of budget. Press it to open the goal dialog: edit the objective or budget, or remove the goal. A completed goal is read-only — remove it, then arm a new one. +- In the session sidebar, a small target appears next to the session date, colored by the goal's state. + +## Notifications + +While a goal is active, the per-turn "agent is ready" notifications are suppressed — they would just echo the goal loop's own continuations. When the goal settles (complete, blocked, or budget reached) you get one final notification instead, on desktop and as a mobile push. It obeys the same "notify on completion" setting; permission requests, questions, and error notifications keep working as usual throughout. + +## Token budget + +In **Settings → Chat → Goal** you can set a default token budget for new goals. When a goal reaches its budget it stops as "budget reached" instead of spending more — you can raise the budget and resume from the goal dialog. + +## Keep in mind + +- The goal loop runs in the OpenChamber server, not in your browser tab. Close the tab, lock the phone — the agent keeps working, and you get a notification when the goal settles. The server (desktop app or `openchamber` process) must stay running. +- Goals use your session's own provider and model, including the auditor calls — nothing leaves the providers you already use. +- One goal per session at a time. + +## Related + +- [Scheduled Tasks](/scheduled-tasks/) — run a prompt on a schedule; enable "Run as goal" there to make a scheduled run pursue its prompt to completion +- [Notifications](/notifications/) — how you hear about a finished goal diff --git a/packages/docs/content/docs/uk/scheduled-tasks.mdx b/packages/docs/content/docs/uk/scheduled-tasks.mdx index a2e3346874..de2b055ad6 100644 --- a/packages/docs/content/docs/uk/scheduled-tasks.mdx +++ b/packages/docs/content/docs/uk/scheduled-tasks.mdx @@ -20,6 +20,8 @@ description: Запускайте промпт автоматично за ро Ви можете запустити будь-яке завдання негайно через **run now**, щоб перевірити, що воно робить те, що ви очікуєте. +Позначте **Run as goal**, щоб запуск доводив промпт до завершення, а не зупинявся після однієї відповіді — див. [Цілі сесії](/session-goals/). + ## Як виглядає успіх Після запуску завдання показує, коли воно востаннє виконувалося, чи воно успішне, і посилання на сесію, яку воно створило. Якщо запуск збоїть, помилка теж показується там. diff --git a/packages/docs/content/docs/uk/session-goals.mdx b/packages/docs/content/docs/uk/session-goals.mdx new file mode 100644 index 0000000000..d1a49d238a --- /dev/null +++ b/packages/docs/content/docs/uk/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Цілі сесії +description: Перетворіть промпт на ціль, до якої агент рухається автоматично. +--- + +# Цілі сесії + +Ціль перетворює один промпт на фінішну пряму. Замість підштовхувати агента словом «продовжуй» після кожної відповіді, ви ставите ціль один раз — і OpenChamber автоматично веде сесію до неї, перевіряючи прогрес незалежним аудитором після кожного ходу. Робота триває, навіть поки вас немає поруч. + +## Як розпочати ціль + +1. Натисніть кнопку-мішень у полі вводу. Вона засвітиться — режим цілі увімкнено. +2. Напишіть промпт і надішліть. Це повідомлення стане формулюванням цілі. + +Це працює однаково і в наявній сесії, і в чернетці нової: увімкніть мішень, напишіть перше повідомлення, надішліть — нова сесія почнеться вже з активною ціллю. + +### Інші способи розпочати ціль + +- **З відповіді агента**: у діалозі «Start new session from this answer» позначте **Run as goal** — відповідь передається як завдання, яке нова сесія виконує до завершення (комбінуйте з **Create worktree** для ізольованого запуску). +- **З плану**: виконуючи збережений план у новій сесії чи worktree, позначте **Run as goal** у діалозі. Формулюванням цілі стане зміст плану, тож аудитор судитиме прогрес за самим планом. +- **За розкладом**: позначте **Run as goal** у [запланованому завданні](/scheduled-tasks/), щоб регулярні запуски доводили промпт до завершення. + +## Формулюйте ціль самодостатньо + +Аудитор прогресу бачить лише ваше формулювання цілі та останню відповідь агента — без історії чату. Тому пишіть повідомлення-ціль так, щоб людина без контексту розмови зрозуміла, як виглядає завершений стан. + +- Добре: «Додай тести для модуля експорту й доведи весь тестовий набір до зеленого стану.» +- Не дуже: «Виправ це» або «Продовжуй з тією ідеєю.» + +Для дрібних контекстних уточнень ціль не потрібна — просто надішліть звичайне повідомлення. + +## Як це працює + +Коли агент зупиняється і сесія трохи затихає, OpenChamber: + +1. Просить малу, дешеву модель оцінити останній хід відносно цілі: продовжувати, готово чи глухий кут? +2. Якщо вердикт «продовжувати» — надсилає промпт продовження, і агент береться за роботу знову. +3. Якщо ціль перевірено досягнута — вона завершується, а ви отримуєте сповіщення. +4. Якщо агент справді застряг (потрібна ваша участь), ціль зупиняється як заблокована — але лише після того, як аудитор скаже це тричі поспіль, тож разова заминка ніколи не завершує ціль. + +Є й жорсткі запобіжники: опційний бюджет токенів, ліміт автоматичних продовжень і зупинка при помилці ходу. Якщо контекст сесії стиснеться посеред роботи, ціль просто продовжиться — впертися у вікно контексту означає, що робота не завершена. + +### Зупинка і відновлення + +- **Кнопка стоп** обриває поточний хід і призупиняє ціль — ваше явне «стоп» завжди сильніше за цикл. +- **Pause** на смужці цілі робить те саме з іншого боку: призупиняє ціль і зупиняє поточний хід. +- Поки ціль на паузі, спілкуйтеся як завжди — цикл не втручається. +- **Resume** знову вмикає цикл: на сесії у простої промпт продовження летить негайно; якщо агент саме працює — цикл тихо підхопиться на його наступній паузі. + +## Спостереження і керування + +- Смужка над полем вводу показує останню нотатку прогресу, статус і використання токенів, а також кнопку призупинення/відновлення. Коли агент зупинився, а ціль активна, смужка показує обертовий **Оцінювання…** — це вікно тиші та робота аудитора. +- Кнопка-мішень світиться, поки ціль працює (синім), стає зеленою при завершенні та червоною, коли ціль заблокована чи вичерпала бюджет. Натисніть її, щоб відкрити діалог цілі: відредагувати формулювання чи бюджет або видалити ціль. Завершена ціль лише для читання — видаліть її, а тоді вмикайте нову. +- У бічній панелі сесій біля дати сесії з'являється маленька мішень, забарвлена за станом цілі. + +## Сповіщення + +Поки ціль активна, сповіщення «агент готовий» після кожного ходу придушені — вони лише повторювали б продовження самого циклу. Коли ціль завершується (готово, заблоковано чи вичерпано бюджет), натомість приходить одне фінальне сповіщення — на десктопі та мобільним пушем. Воно поважає те саме налаштування «сповіщати про завершення»; запити дозволів, питання та сповіщення про помилки працюють як завжди. + +## Бюджет токенів + +У **Налаштування → Чат → Ціль** можна задати типовий бюджет токенів для нових цілей. Досягнувши бюджету, ціль зупиняється як «бюджет вичерпано» замість витрачати більше — можна підняти бюджет і відновити її з діалогу цілі. + +## Варто знати + +- Цикл цілі працює в сервері OpenChamber, а не у вкладці браузера. Закрийте вкладку, заблокуйте телефон — агент працює далі, а коли ціль завершиться, прийде сповіщення. Сервер (десктопна апка або процес `openchamber`) має залишатися запущеним. +- Цілі використовують провайдера й модель самої сесії, включно з викликами аудитора — нічого не йде до провайдерів, якими ви не користуєтесь. +- Одна ціль на сесію за раз. + +## Дивіться також + +- [Заплановані завдання](/scheduled-tasks/) — запуск промпта за розкладом; увімкніть там «Виконати як ціль», щоб запланований запуск довів промпт до завершення +- [Сповіщення](/notifications/) — як ви дізнаєтеся про завершену ціль diff --git a/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx b/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx index 1d642501d1..f95a41c1b9 100644 --- a/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx +++ b/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx @@ -20,6 +20,8 @@ description: 按计划自动运行提示词。 你可以用 **run now** 立即运行任何任务,以检查它是否按预期工作。 +勾选**作为目标运行**,运行就会把提示词推进到完成,而不是在一次回复后停下 — 参见[会话目标](/session-goals/)。 + ## 成功的样子 运行之后,任务会显示它上次运行的时间、是否成功,以及指向它所创建会话的链接。如果某次运行失败,错误也会显示在那里。 diff --git a/packages/docs/content/docs/zh-cn/session-goals.mdx b/packages/docs/content/docs/zh-cn/session-goals.mdx new file mode 100644 index 0000000000..6e3a4db1c5 --- /dev/null +++ b/packages/docs/content/docs/zh-cn/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: 会话目标 +description: 将一条提示词变成目标,代理会自动持续朝它推进。 +--- + +# 会话目标 + +目标把一条提示词变成终点线。你不用在每次回复后催促代理"继续",只需设置一次目标 — OpenChamber 会自动让会话朝目标推进,并在每一轮之后用独立的审核模型检查进度。即使你不在电脑前,它也会继续运行。 + +## 启动目标 + +1. 按下输入框中的靶心按钮。它亮起 — 目标模式已就绪。 +2. 输入提示词并发送。这条消息就成为目标内容。 + +在现有会话和新会话草稿中都一样:启用靶心,写下第一条消息,发送 — 新会话一开始就带着已激活的目标。 + +### 启动目标的更多方式 + +- **从代理的回复**:在 "Start new session from this answer" 对话框中勾选**作为目标运行** — 回复将作为任务移交,新会话会把它执行到完成(与 **Create worktree** 结合可获得隔离的运行环境)。 +- **从计划**:在新会话或 worktree 中实施已保存的计划时,在对话框中勾选**作为目标运行**。目标会携带计划内容,因此审核会以实际计划为准判断进度。 +- **按计划**:在[计划任务](/scheduled-tasks/)上勾选**作为目标运行**,让周期性运行把提示词推进到完成。 + +## 写一个自包含的目标 + +进度审核模型只能看到你的目标和代理的最新回复 — 看不到聊天历史。因此,请把目标消息写得让一个不了解对话上下文的人也能明白完成状态是什么样子。 + +- 好的写法:"为导出模块添加测试,并让整个测试套件通过。" +- 不太好的写法:"修一下" 或 "按那个思路继续。" + +对于小的上下文跟进,不需要目标 — 发一条普通消息就行。 + +## 工作原理 + +当代理停下且会话安静片刻后,OpenChamber 会: + +1. 让一个小而便宜的模型将最新一轮与目标对照审核:继续、完成,还是卡住了? +2. 如果判定是"继续",就发送续跑提示词,代理重新开始工作。 +3. 如果目标已被可验证地达成,目标即完成,你会收到通知。 +4. 如果代理真的卡住了(需要你的介入),目标会以"已阻塞"停止 — 但只有在审核连续三次这样判定之后,所以一次小挫折绝不会终结目标。 + +还有硬性安全限制:可选的令牌预算、自动续跑次数上限,以及轮次出错时停止。如果会话上下文在工作途中被压缩,目标会照常继续 — 撞上上下文窗口本身就证明工作还没完成。 + +### 停止与恢复 + +- **停止按钮**会中断正在运行的轮次并暂停目标 — 你明确的"停"永远优先于循环。 +- 条带上的**暂停**从另一个方向做同样的事:暂停目标并停止正在运行的轮次。 +- 暂停期间正常聊天即可 — 循环不会打扰。 +- **继续**重新启动循环:在空闲会话上,续跑提示词会立即发出;如果代理恰好在工作,循环会在它下一次停顿时静静接上。 + +## 查看与管理 + +- 输入框上方的条带显示目标的最新进度备注、状态和令牌用量,并内置暂停/继续按钮。当代理已停止而目标仍激活时,条带会显示旋转的**评估中…** — 那是静默窗口和审核正在运行。 +- 靶心按钮在目标运行时保持点亮(蓝色),完成时变绿,阻塞或预算耗尽时变红。按下它可打开目标对话框:编辑目标或预算,或移除目标。已完成的目标为只读 — 先移除,再启动新目标。 +- 在会话侧边栏中,会话日期旁会出现一个小靶心,颜色对应目标状态。 + +## 通知 + +目标激活期间,每轮的"代理就绪"通知会被抑制 — 它们只会重复循环自己的续跑。目标尘埃落定时(完成、阻塞或达到预算),你会收到一条最终通知,出现在桌面并作为移动推送。它遵循同一个"完成时通知"设置;权限请求、提问和错误通知全程照常工作。 + +## 令牌预算 + +在 **设置 → 聊天 → 目标** 中可以为新目标设置默认令牌预算。目标达到预算时会以"已达预算"停止而不再消耗 — 你可以提高预算并从目标对话框中恢复。 + +## 注意事项 + +- 目标循环运行在 OpenChamber 服务器中,而不是浏览器标签页里。关掉标签页、锁上手机 — 代理继续工作,目标尘埃落定时你会收到通知。服务器(桌面应用或 `openchamber` 进程)必须保持运行。 +- 目标使用会话自身的提供商和模型,包括审核调用 — 数据不会流向你未在使用的提供商。 +- 每个会话同时只能有一个目标。 + +## 相关 + +- [计划任务](/scheduled-tasks/) — 按计划运行提示词;在那里启用"作为目标运行",让计划运行将提示词推进到完成 +- [通知](/notifications/) — 如何得知目标已完成 diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index 2195548d08..9529d2c0fe 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -154,6 +154,20 @@ "ja": "スケジュールタスク" } }, + { + "label": "Session Goals", + "link": "/session-goals/", + "translations": { + "uk": "Цілі сесії", + "zh-CN": "会话目标", + "es": "Objetivos de sesión", + "pt-BR": "Objetivos de sessão", + "ko": "세션 목표", + "pl": "Cele sesji", + "fr": "Objectifs de session", + "ja": "セッションゴール" + } + }, { "label": "Project Actions", "link": "/project-actions/", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 32e607eb29..c9e727a270 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -100,6 +100,8 @@ import { } from './attachmentCitations'; import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip'; +import { SessionGoalRow } from '@/components/chat/SessionGoalRow'; +import { SessionGoalButton, SessionGoalObjectiveCounter } from '@/components/chat/SessionGoalButton'; import type { Part } from '@opencode-ai/sdk/v2/client'; const MAX_VISIBLE_TEXTAREA_LINES = 8; @@ -4904,6 +4906,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo > {isMobile && !mobileComposerExpanded ? (
+ = ({ onOpenSettings, scrollTo
) : ( <> + = ({ onOpenSettings, scrollTo permissionAutoAcceptEnabled={permissionAutoAcceptEnabled} handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle} /> + +
@@ -5382,6 +5402,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle} withTooltip /> + +
diff --git a/packages/ui/src/components/chat/SessionGoalButton.tsx b/packages/ui/src/components/chat/SessionGoalButton.tsx new file mode 100644 index 0000000000..8450729f57 --- /dev/null +++ b/packages/ui/src/components/chat/SessionGoalButton.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useSessionGoal } from '@/hooks/useSessionGoal'; +import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; +import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata'; +import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; + +interface SessionGoalButtonProps { + sessionId: string | null; + directory?: string; + /** Session draft is open — the goal arms for the session the draft creates. */ + draftOpen?: boolean; + footerIconButtonClass: string; + iconSizeClass: string; + withTooltip?: boolean; +} + +// Composer target button — the goal switch. With no live goal one tap arms +// goal mode (the next sent prompt becomes the objective; works on drafts +// too) and a second tap disarms. While a goal is live the target stays lit +// (info while running, success when complete, error when blocked / out of +// budget) and tapping opens the manage dialog. +export const SessionGoalButton: React.FC = React.memo(({ + sessionId, + directory, + draftOpen = false, + footerIconButtonClass, + iconSizeClass, + withTooltip = false, +}) => { + const { t } = useI18n(); + const { goal, enabled } = useSessionGoal(sessionId ?? '', directory); + const armed = useSessionGoalArmStore((state) => state.armed); + const setArmed = useSessionGoalArmStore((state) => state.setArmed); + const [dialogOpen, setDialogOpen] = React.useState(false); + + // The goal loop runs in the web server; the VS Code extension only renders + // goal state. Arming a goal there would create one nothing drives, so the + // entry point is hidden entirely. + if (isVSCodeRuntime() || !enabled || (!sessionId && !draftOpen)) { + return null; + } + + // A settled goal no longer drives the loop — the button goes back to being + // an arm switch, while still tinting with the outcome color. + const liveGoal = goal && goal.status !== 'complete' ? goal : null; + const isEngaged = armed || Boolean(liveGoal); + + const colorClass = (() => { + if (goal?.status === 'complete') return 'text-[var(--status-success)]'; + if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]'; + if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]'; + return ''; + })(); + + const label = goal + ? t('chat.goal.button.manageAria') + : (armed ? t('chat.goal.button.disarmAria') : t('chat.goal.button.armAria')); + + // Any existing goal (live or completed) opens the manage dialog — a + // completed goal must be removed there before a new one can be armed. + const handleClick = () => { + if (goal) { + setDialogOpen(true); + return; + } + setArmed(!armed); + }; + + const button = ( + + ); + + return ( + <> + {withTooltip ? ( + + {button} + {label} + + ) : button} + {sessionId ? ( + + ) : null} + + ); +}); + +SessionGoalButton.displayName = 'SessionGoalButton'; + +interface SessionGoalObjectiveCounterProps { + /** Current composer text length — the armed message becomes the objective. */ + length: number; +} + +// Tiny hot-path leaf next to the target button: while goal mode is armed the +// typed message becomes the objective, which the server clamps to 2000 +// chars — surface that limit during typing instead of truncating silently. +// Renders null when not armed, so normal typing shows nothing. +export const SessionGoalObjectiveCounter: React.FC = React.memo(({ length }) => { + const { t } = useI18n(); + const armed = useSessionGoalArmStore((state) => state.armed); + + if (!armed || length === 0) { + return null; + } + + const over = length > SESSION_GOAL_OBJECTIVE_CHAR_LIMIT; + return ( + + {length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT} + + ); +}); + +SessionGoalObjectiveCounter.displayName = 'SessionGoalObjectiveCounter'; diff --git a/packages/ui/src/components/chat/SessionGoalDialog.tsx b/packages/ui/src/components/chat/SessionGoalDialog.tsx new file mode 100644 index 0000000000..ab87ba9e38 --- /dev/null +++ b/packages/ui/src/components/chat/SessionGoalDialog.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { NumberInput } from '@/components/ui/number-input'; +import { toast } from '@/components/ui'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useSessionGoal } from '@/hooks/useSessionGoal'; +import { + formatGoalTokens, + SESSION_GOAL_OBJECTIVE_CHAR_LIMIT, +} from '@/lib/sessionGoalMetadata'; +import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; +import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions'; +import { useI18n } from '@/lib/i18n'; + +interface SessionGoalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string; + directory?: string; +} + +// Create/manage dialog for the session goal: objective + optional token +// budget on creation; status, usage, latest audit note and lifecycle actions +// (pause/resume/complete/clear) once a goal exists. +export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) { + const { t } = useI18n(); + const { goal } = useSessionGoal(sessionId, directory); + + const [objective, setObjective] = React.useState(''); + const [budgetEnabled, setBudgetEnabled] = React.useState(false); + const [tokenBudget, setTokenBudget] = React.useState(200_000); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + if (!open) return; + setObjective(goal?.objective ?? ''); + setBudgetEnabled(Boolean(goal?.tokenBudget)); + setTokenBudget(goal?.tokenBudget ?? 200_000); + // Seed the form only when the dialog opens; live goal updates while it is + // open must not clobber the user's edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const run = React.useCallback(async (action: () => Promise, closeAfter: boolean) => { + setBusy(true); + try { + await action(); + if (closeAfter) onOpenChange(false); + } catch (error) { + console.warn('[session-goal] action failed:', error); + toast.error(t('chat.goal.toast.actionFailed')); + } finally { + setBusy(false); + } + }, [onOpenChange, t]); + + const trimmedObjective = objective.trim(); + const objectiveChanged = trimmedObjective !== (goal?.objective ?? ''); + const budgetValue = budgetEnabled ? tokenBudget : null; + const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null); + // A completed goal is read-only: remove it and arm a new one instead of + // "saving" over the outcome (re-saving used to spawn a fresh active goal + // that the auditor instantly re-completed — a confusing status flash). + const isCompleted = goal?.status === 'complete'; + const canSave = !isCompleted && trimmedObjective.length > 0 && (!goal || objectiveChanged || budgetChanged); + + const handleSave = () => run( + () => setSessionGoal(sessionId, directory, { objective: trimmedObjective, tokenBudget: budgetValue }, goal), + true, + ); + + return ( + + + + {goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate')} + + +
+ {goal && ( +
+
+
+ {goal.note ? ( +

{goal.note}

+ ) : null} + {/* Only failure states carry a reason worth reading; outcomes + like "verified by audit" are noise next to the status dot. */} + {goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? ( +

{goal.statusReason}

+ ) : null} +
+ )} + + {isCompleted ? ( +

{goal.objective}

+ ) : ( + <> +
+
+ {t('chat.goal.dialog.objectiveLabel')} + + {objective.length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT} + +
+