diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6383ec187825c..73cbc5127ca49 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -914,9 +914,6 @@ export const make = Effect.gen(function* () { // Desktop IPC only answers the main window, so register it before its renderer can call in. yield* electronWindow.setMain(window); loadApplication(); - if (environment.isDevelopment) { - window.webContents.openDevTools({ mode: "detach" }); - } window.on("closed", () => { if (!backendHasStarted) mainClosedDuringStartup = true; diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 5b6f447396a0b..20fecb9345a5b 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -1,4 +1,5 @@ import { UpstreamUpdatesSection } from "./UpstreamUpdatesSection"; +import { N2itionConnectionSection } from "./components/N2itionConnectionSection"; import * as Dialog from "../../components/ui/Dialog"; import { useAtom, useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -311,6 +312,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm {config.environment.capabilities.upstreamUpdates === true && ( )} + (null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Synchronize owner RPC status with the selected environment and discard stale responses. + useEffect(() => { + if (!allowed) return; + let closed = false; + void getStatus({ environmentId, input: {} }) + .then((result) => { + if (!closed && result._tag === "Success") setStatus(result.value); + }) + .catch(() => undefined); + return () => { + closed = true; + }; + }, [allowed, environmentId, getStatus]); + const act = async (action: "check" | "reconnect" | "pause" | "resume" | "revoke" | "refresh") => { + if (!allowed || busy || status?.epoch === null || !status) return; + setBusy(true); + setError(null); + const request = + action === "check" + ? getStatus({ environmentId, input: {} }) + : action === "reconnect" + ? reconnect({ environmentId, input: { expectedEpoch: status.epoch } }) + : action === "refresh" + ? refresh({ environmentId, input: {} }) + : control({ environmentId, input: { action, expectedEpoch: status.epoch } }); + await request + .then((result) => { + if (result._tag === "Success") { + setStatus((previous) => + previous?.epoch !== null && + previous?.epoch !== undefined && + result.value.epoch !== null && + previous.epoch > result.value.epoch + ? previous + : result.value, + ); + } else { + setError( + "The connection needs attention. Check status and verify this environment’s account setup.", + ); + } + }) + .catch(() => { + setError("Connection status is unavailable. Reconnect this environment and try again."); + }); + setBusy(false); + }; + if (!allowed || !status || status.state === "disabled") return null; + const revoked = status.localMode === "revoked"; + return ( + + + {status.state.replaceAll("_", " ")} + + Read-only task context. Reconnection verifies the enrolled account and preserves the + current access limits. + + {error ? ( + + {error} + + ) : null} + + void act("check")} + /> + void act("reconnect")} + /> + void act(status.localMode === "active" ? "pause" : "resume")} + /> + void act("refresh")} + /> + + Dialog.alert( + "Revoke N2ITION access?", + "This ends this environment’s access and clears its cached context. Reconnection requires a new owner setup.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Revoke", style: "destructive", onPress: () => void act("revoke") }, + ], + ) + } + /> + + ); +} diff --git a/apps/mobile/src/features/threads/PrivateControls.tsx b/apps/mobile/src/features/threads/PrivateControls.tsx new file mode 100644 index 0000000000000..89afd4dd7b843 --- /dev/null +++ b/apps/mobile/src/features/threads/PrivateControls.tsx @@ -0,0 +1,178 @@ +import type { + EnvironmentId, + PrivateHelperPacket, + PrivateHelpersPolicy, + ThreadId, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { serverEnvironment } from "../../state/server"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function PrivateControls(props: { + environmentId: EnvironmentId; + threadId: ThreadId; + policy: PrivateHelpersPolicy; + canOperate: boolean; +}) { + const [open, setOpen] = useState(false); + const [packets, setPackets] = useState([]); + const [reviewed, setReviewed] = useState(null); + const [busy, setBusy] = useState(false); + const load = useAtomCommand(serverEnvironment.getPrivateHelperState); + const decide = useAtomCommand(serverEnvironment.decidePrivateHelper); + const update = useAtomCommand(threadEnvironment.updateMetadata); + const refresh = async () => { + const result = await load({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }); + if (AsyncResult.isSuccess(result)) setPackets(result.value.packets); + }; + const decision = async (packet: PrivateHelperPacket, value: "approve" | "block") => { + setBusy(true); + try { + const result = await decide({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + packetId: packet.id, + digest: packet.digest, + decision: value, + }, + }); + if (AsyncResult.isSuccess(result)) + setPackets((current) => + current.map((entry) => (entry.id === packet.id ? result.value : entry)), + ); + setReviewed(null); + } catch (error) { + setBusy(false); + throw error; + } + setBusy(false); + }; + return ( + <> + { + setOpen(true); + void refresh(); + }} + className="min-h-11 self-start justify-center px-3" + > + + {props.policy === "disabled" ? "Private" : "Private + helpers"} + + + setOpen(false)} + presentationStyle="pageSheet" + animationType="slide" + > + + + Private controls + + Full conversation context uses Tinfoil. External helpers receive only the exact packet + you review and approve. Auto action permissions stay separate. + + + + + {packets.length === 0 ? ( +

+ Your private coordinator can prepare a self-contained helper packet. Pending reviews + expire after 15 minutes or a server restart. +

+ ) : null} + {packets.map((packet) => ( +
+
+ {packet.modelSelection.instanceId} ·{" "} + {packet.modelSelection.model} +

{packet.status}

+
+

{packet.task.objective}

+
{ + if (event.currentTarget.open) setReviewed(packet.id); + }} + > + + Inspect exact outbound packet + +
+                    {packet.prompt}
+                  
+
+ {packet.status === "awaiting-review" ? ( +
+ + +
+ ) : null} + {packet.artifact === undefined ? null : ( +
+ + Inspect returned artifact + +
+                      {packet.artifact}
+                    
+

+ The private coordinator reviews this artifact before applying or running it. +

+
+ )} +
+ ))} + + + + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index ecf20378cafe6..8ddb515c389f5 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { N2itionSettings } from "./N2itionSettings"; import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; @@ -1607,7 +1608,7 @@ export function BrowserSettingsPanel() {
- + diff --git a/apps/web/src/components/settings/N2itionSettings.tsx b/apps/web/src/components/settings/N2itionSettings.tsx new file mode 100644 index 0000000000000..772aedf5855c9 --- /dev/null +++ b/apps/web/src/components/settings/N2itionSettings.tsx @@ -0,0 +1,195 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { N2itionStatus } from "@t3tools/contracts/n2itionConnection"; +import { useEffect, useEffectEvent, useState } from "react"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsSection } from "./settingsLayout"; + +export function N2itionSettings() { + const { environment, scope } = useSettingsScope(); + if (scope.kind !== "environment" || !environment) + return ( + +

Choose an environment to inspect Teddy’s CRM connection.

+
+ ); + return ; +} +function N2itionConnection() { + const { environment } = useSettingsScope(); + const environmentId = environment!.environmentId; + const allowed = useAtomValue(serverEnvironment.n2itionControl.permissionAtom(environmentId)); + const getStatus = useAtomCommand(serverEnvironment.n2itionStatus, { reportFailure: false }); + const control = useAtomCommand(serverEnvironment.n2itionControl, { reportFailure: false }); + const refresh = useAtomCommand(serverEnvironment.n2itionRefresh, { reportFailure: false }); + const reconnect = useAtomCommand(serverEnvironment.n2itionReconnect, { reportFailure: false }); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState(false); + const check = useEffectEvent(async () => { + if (!allowed) return; + const result = await getStatus({ environmentId, input: {} }); + if (result._tag === "Success") { + setStatus((previous) => + previous?.epoch !== null && + previous?.epoch !== undefined && + result.value.epoch !== null && + previous.epoch > result.value.epoch + ? previous + : result.value, + ); + setError(null); + } else + setError("Connection status unavailable. Check this environment’s connection and try again."); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep owner connection status synchronized with the remote environment while its settings page is mounted. + useEffect(() => { + if (!allowed) return; + // oxlint-disable-next-line react/set-state-in-effect -- The owner RPC updates state after its response arrives. + void check(); + const timer = setInterval(() => void check(), 30000); + return () => clearInterval(timer); + }, [allowed]); + const act = async (action: "pause" | "resume" | "revoke" | "refresh" | "reconnect") => { + if (!status || status.epoch === null) return; + setBusy(true); + setError(null); + try { + const result = + action === "refresh" + ? await refresh({ environmentId, input: {} }) + : action === "reconnect" + ? await reconnect({ environmentId, input: { expectedEpoch: status.epoch } }) + : await control({ environmentId, input: { action, expectedEpoch: status.epoch } }); + if (result._tag === "Success") { + setStatus((previous) => + previous?.epoch !== null && + previous?.epoch !== undefined && + result.value.epoch !== null && + previous.epoch > result.value.epoch + ? previous + : result.value, + ); + setConfirm(false); + } else + setError( + "The action could not finish. Refresh status to see whether access is paused, expired or requires attention in N2ITION.", + ); + } catch { + setError( + "The action could not finish. Refresh status to see whether access is paused, expired or requires attention in N2ITION.", + ); + } + setBusy(false); + }; + return ( + +
+

+ Read-only task context for conversations with Teddy. All task categories and notes are + included in the approved connection. +

+ {!allowed ? ( +

Settings access is required to view or control this connection.

+ ) : status ? ( + <> +

+ {status.state.replaceAll("_", " ")} + {status.source + ? ` · ${status.source === "synthetic" ? "Made-up test tasks" : "N2ITION tasks"}` + : ""} +

+

+ Last checked:{" "} + {status.lastCheckedAt + ? new Date(status.lastCheckedAt).toLocaleString() + : "No successful reads yet"} + . Tasks: {status.state === "connected" ? status.taskCount : "unverified"}. +

+ {status.cadenceHours && ( +

+ Checks every {status.cadenceHours} hour(s) while this environment is running. + Current questions trigger a fresh read. +

+ )} + {status.state === "disabled" && ( +

The connection has not been configured in this environment.

+ )} + {status.lastError && ( +

+ Latest issue: {status.lastError.replaceAll("_", " ")}. Access may also be paused or + revoked in N2ITION’s settings. +

+ )} +
+ + + + + +
+ {confirm && ( +
+

+ Revoking ends this environment’s access and clears cached context. Reconnection + requires a new owner-approved setup. Existing conversations retain information + under their own retention settings. +

+
+ + +
+
+ )} +

+ Last 30 days: {status.usage.reads} context reads · {status.usage.notesReads} reads + with notes · {status.usage.refreshes} refreshes · {status.usage.failures} failures. + Background AI calls: 0. +

+

+ Usage counts stay in this environment and contain no task text. Sleeping or + disconnected computers can miss checks; Teddy will identify unavailable context when + you ask. +

+ + ) : ( +

Checking connection…

+ )} + {error &&

{error}

} +
+
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarAgentNavigation.tsx b/apps/web/src/components/sidebar/SidebarAgentNavigation.tsx new file mode 100644 index 0000000000000..0bdbaddd0b9c2 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarAgentNavigation.tsx @@ -0,0 +1,413 @@ +import { threadEnvironment } from "../../state/threads"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { selectAgentAutoTargets } from "./agentPermissions"; +import { createAgentShortcutActions } from "./agentShortcut"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Link, useLocation } from "@tanstack/react-router"; +import { PencilIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + persistClientSettingsUpdate, + useClientSettings, + useClientSettingsHydrated, +} from "../../hooks/useSettings"; +import { useEnvironmentIdentities } from "../../state/environments"; +import { AuthOrchestrationOperateScope, AuthSettingsWriteScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "../../state/session"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { Switch } from "../ui/switch"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useUiStateStore } from "../../uiStateStore"; +import { resolveAgentStatus } from "./agentStatus"; +import { useQuietThreadShell, useQuietThreadShells } from "../../state/entities"; +import { buildThreadRouteParams, isThreadRoutePathname } from "../../threadRoutes"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar"; + +function TeddyIcon() { + return ( + + ); +} + +export function SidebarAgentNavigation() { + const shortcut = useClientSettings((settings) => settings.agentShortcut ?? null); + const hydrated = useClientSettingsHydrated(); + const { setOpenMobile } = useSidebar(); + const pathname = useLocation({ select: (location) => location.pathname }); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [actions] = useState(() => createAgentShortcutActions(shortcut)); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the action controller synchronized with the saved shortcut. + useEffect(() => { + actions.syncShortcut(shortcut); + }, [actions, shortcut]); + const active = shortcut !== null && isThreadRoutePathname(pathname, shortcut); + const name = shortcut?.name ?? "Teddy"; + + return ( + <> + + +
+
+ setOpenMobile(false)} + /> + ) : undefined + } + onClick={shortcut ? undefined : () => setOpen(true)} + disabled={!hydrated} + isActive={active} + title={shortcut ? `Open ${name}` : "Choose Teddy’s conversation"} + > + + {name} + {shortcut && } + +
+ +
+
+
+ { + if (!actions.isSaving()) setOpen(value); + }} + > + {open && ( + setOpen(false)} + actions={actions} + saving={saving} + setSaving={setSaving} + /> + )} + + + ); +} + +function AgentShortcutEditor({ + onClose, + actions, + saving, + setSaving, +}: { + onClose: () => void; + actions: ReturnType; + saving: boolean; + setSaving: (saving: boolean) => void; +}) { + const shortcut = useClientSettings((settings) => settings.agentShortcut ?? null); + const removeCoordinator = useAtomCommand(serverEnvironment.setApprovalCoordinator, { + reportFailure: false, + }); + const threads = useQuietThreadShells(); + const environments = useEnvironmentIdentities(); + const [name, setName] = useState(shortcut?.name ?? "Teddy"); + const [selected, setSelected] = useState(shortcut ? scopedThreadKey(shortcut) : ""); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const choices = threads.map((thread) => ({ + environmentId: thread.environmentId, + threadId: thread.id, + label: thread.title, + })); + // Retain the identity during disconnects and when the thread leaves the shell's recent window. + if ( + shortcut && + !choices.some((choice) => scopedThreadKey(choice) === scopedThreadKey(shortcut)) + ) { + choices.unshift({ ...shortcut, label: shortcut.name }); + } + const choice = choices.find((item) => scopedThreadKey(item) === selected); + const matching = choices.filter((item) => + item.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ); + const save = async (clear = false) => { + const next = clear + ? null + : choice + ? { environmentId: choice.environmentId, threadId: choice.threadId, name: name.trim() } + : undefined; + if (next === undefined || (next !== null && !next.name) || actions.isSaving()) return; + setSaving(true); + setError(null); + try { + const saved = await actions.save( + shortcut, + next, + async (ref) => { + const result = await removeCoordinator({ + environmentId: ref.environmentId, + input: { threadId: ref.threadId, enabled: false }, + }); + if (result._tag === "Failure") throw new Error("Coordinator revocation failed"); + }, + async (value) => { + await persistClientSettingsUpdate((current) => ({ ...current, agentShortcut: value })); + }, + ); + if (saved) onClose(); + } catch { + setError( + "Could not revoke the previous agent’s approval authority or save the shortcut. Check the environment connection and try again.", + ); + } + setSaving(false); + }; + + return ( + + + Your agent + + A permanent place below Home. Choose a name and an existing conversation. This shortcut is + saved on this device. + + +
{ + event.preventDefault(); + void save(); + }} + > + +
+ + + + {matching.length === 0 && ( +

+ No matching conversations. Open the conversation in T3, then return here. +

+ )} + {shortcut && } + {shortcut && ( + + )} + {error && ( +

+ {error} +

+ )} +
+
+ + {shortcut && ( + + )} + + + +
+
+ ); +} + +function AgentStatus({ threadRef }: { threadRef: ScopedThreadRef }) { + const thread = useQuietThreadShell(threadRef); + const key = scopedThreadKey(threadRef); + const localVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[key]); + const status = resolveAgentStatus(thread, localVisitedAt); + return ( + + + ); +} + +function AgentApprovalAuthority({ + threadRef, + actions, + saving, +}: { + threadRef: ScopedThreadRef; + actions: ReturnType; + saving: boolean; +}) { + const granted = useEnvironmentSettings( + threadRef.environmentId, + (settings) => settings.approvalCoordinatorThreadIds ?? [], + ); + const update = useAtomCommand(serverEnvironment.setApprovalCoordinator); + const writable = useEnvironmentScope(threadRef.environmentId, AuthSettingsWriteScope); + const enabled = granted.includes(threadRef.threadId); + return ( +
+ + void actions.approval(threadRef, async () => { + await update({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, enabled: checked }, + }); + }) + } + /> +
+

Review routine approvals

+

+ Applies immediately to this agent’s own assignments. Reads, searches, tests and reversible + edits can be approved one at a time. Publishing, deletion, spending, credentials, + permission changes and private-data sharing come to you. +

+
+
+ ); +} + +function AgentAutoPermissions({ threadRef }: { threadRef: ScopedThreadRef }) { + const threads = useQuietThreadShells(); + const agent = useQuietThreadShell(threadRef); + const writable = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope); + const setMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const targets = selectAgentAutoTargets(threads, threadRef); + const apply = async () => { + setSaving(true); + setError(null); + try { + for (const thread of targets) { + const result = await setMode({ + environmentId: threadRef.environmentId, + input: { + threadId: thread.id, + runtimeMode: "auto", + createdAt: new Date().toISOString(), + }, + }); + if (result._tag === "Failure") { + setError( + `Could not change ${thread.title}. Earlier changes were saved; retry to finish.`, + ); + break; + } + } + } catch { + setError("Could not finish changing permissions. Retry to finish the remaining threads."); + } + setSaving(false); + }; + return ( +
+

Sub-agent permissions

+

+ Auto lets the provider review routine actions. Apply it to this agent and its existing + direct sub-agents; future sub-agents inherit it. Providers without Auto support can still + ask. Existing approval cards may need one final response. Change an individual thread’s mode + in its composer. +

+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarHomeNavigation.tsx b/apps/web/src/components/sidebar/SidebarHomeNavigation.tsx index a3aeb998b5792..3db4b76f11e59 100644 --- a/apps/web/src/components/sidebar/SidebarHomeNavigation.tsx +++ b/apps/web/src/components/sidebar/SidebarHomeNavigation.tsx @@ -1,3 +1,4 @@ +import { SidebarAgentNavigation } from "./SidebarAgentNavigation"; import { Link, useLocation } from "@tanstack/react-router"; import { HouseIcon } from "lucide-react"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar"; @@ -18,6 +19,7 @@ export function SidebarHomeNavigation() { + ); } diff --git a/apps/web/src/components/sidebar/agentPermissions.test.ts b/apps/web/src/components/sidebar/agentPermissions.test.ts new file mode 100644 index 0000000000000..35d3e01e01875 --- /dev/null +++ b/apps/web/src/components/sidebar/agentPermissions.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { selectAgentAutoTargets } from "./agentPermissions"; + +it("changes the agent first and only its direct app-owned subagents in this environment", () => { + const environmentId = EnvironmentId.make("local"); + const threadId = ThreadId.make("teddy"); + const parent = { + id: threadId, + environmentId, + runtimeMode: "approval-required" as const, + source: { creationSource: "mcp" as const, archivedAt: null, deletedAt: null }, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + }; + const child = { + ...parent, + id: ThreadId.make("child"), + lineage: { + parentThreadId: threadId, + relationshipToParent: "subagent" as const, + rootThreadId: threadId, + }, + }; + const threads = [ + child, + { ...child, id: ThreadId.make("auto"), runtimeMode: "auto" as const }, + { ...child, environmentId: EnvironmentId.make("remote") }, + { ...child, source: { ...child.source, creationSource: "provider" as const } }, + { ...child, lineage: { ...child.lineage, relationshipToParent: "fork" as const } }, + parent, + ]; + expect( + selectAgentAutoTargets(threads, { environmentId, threadId }).map((thread) => thread.id), + ).toEqual([threadId, child.id]); + expect(selectAgentAutoTargets([child], { environmentId, threadId })).toEqual([]); +}); diff --git a/apps/web/src/components/sidebar/agentPermissions.ts b/apps/web/src/components/sidebar/agentPermissions.ts new file mode 100644 index 0000000000000..0e4281654539f --- /dev/null +++ b/apps/web/src/components/sidebar/agentPermissions.ts @@ -0,0 +1,33 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +type AgentThread = Pick< + EnvironmentThreadShell, + "id" | "environmentId" | "lineage" | "runtimeMode" +> & { + readonly source: Pick< + EnvironmentThreadShell["source"], + "creationSource" | "archivedAt" | "deletedAt" + >; +}; + +export function selectAgentAutoTargets( + threads: readonly T[], + agent: ScopedThreadRef, +): T[] { + const local = threads.filter((thread) => thread.environmentId === agent.environmentId); + const parent = local.find((thread) => thread.id === agent.threadId); + if (!parent) return []; + return [ + parent, + ...local.filter( + (thread) => + thread.id !== agent.threadId && + thread.source.creationSource === "mcp" && + thread.source.archivedAt === null && + thread.source.deletedAt === null && + thread.lineage.relationshipToParent === "subagent" && + thread.lineage.parentThreadId === agent.threadId, + ), + ].filter((thread) => thread.runtimeMode !== "auto"); +} diff --git a/apps/web/src/components/sidebar/agentShortcut.test.ts b/apps/web/src/components/sidebar/agentShortcut.test.ts new file mode 100644 index 0000000000000..7a0cfd4e2cc2b --- /dev/null +++ b/apps/web/src/components/sidebar/agentShortcut.test.ts @@ -0,0 +1,134 @@ +import { expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { createAgentShortcutActions, saveAgentShortcut } from "./agentShortcut"; + +const old = { + environmentId: EnvironmentId.make("old"), + threadId: ThreadId.make("old"), + name: "Old", +}; +const next = { + environmentId: EnvironmentId.make("new"), + threadId: ThreadId.make("new"), + name: "New", +}; + +it("revokes the old environment before replacing or clearing the shortcut without a settings snapshot", async () => { + for (const replacement of [next, null]) { + const events: string[] = []; + await saveAgentShortcut( + old, + replacement, + async (ref) => { + events.push(`revoke:${ref.threadId}`); + }, + async () => { + events.push("save"); + }, + ); + expect(events).toEqual(["revoke:old", "save"]); + } +}); + +it("retains the old shortcut when revocation fails and skips revocation for a rename", async () => { + const save = vi.fn(); + await expect( + saveAgentShortcut( + old, + next, + async () => { + throw new Error("offline"); + }, + save, + ), + ).rejects.toThrow("offline"); + expect(save).not.toHaveBeenCalled(); + const revoke = vi.fn(); + await saveAgentShortcut(old, { ...old, name: "Renamed" }, revoke, save); + expect(revoke).not.toHaveBeenCalled(); + expect(save).toHaveBeenCalledOnce(); +}); + +it.each([next, null])( + "orders pending approval before replacement and blocks late grants", + async (replacement) => { + const actions = createAgentShortcutActions(old); + let finishApproval!: () => void; + let finishRevoke!: () => void; + let finishSave!: () => void; + let markRevokeStarted!: () => void; + let markSaveStarted!: () => void; + const revokeStarted = new Promise((resolve) => { + markRevokeStarted = resolve; + }); + const saveStarted = new Promise((resolve) => { + markSaveStarted = resolve; + }); + const approvalPending = new Promise((resolve) => { + finishApproval = resolve; + }); + const revokePending = new Promise((resolve) => { + finishRevoke = resolve; + }); + const savePending = new Promise((resolve) => { + finishSave = resolve; + }); + let granted = false; + const approval = actions.approval(old, async () => { + await approvalPending; + granted = true; + }); + const duplicate = vi.fn(); + expect(await actions.approval(old, duplicate)).toBe(false); + const save = actions.save( + old, + replacement, + async () => { + markRevokeStarted(); + await revokePending; + granted = false; + }, + async () => { + markSaveStarted(); + await savePending; + }, + ); + const lateGrant = vi.fn(async () => { + granted = true; + }); + expect(await actions.approval(old, lateGrant)).toBe(false); + expect(await actions.save(old, replacement, vi.fn(), vi.fn())).toBe(false); + finishApproval(); + await approval; + await revokeStarted; + expect(granted).toBe(true); + expect(await actions.approval(old, lateGrant)).toBe(false); + finishRevoke(); + await saveStarted; + expect(granted).toBe(false); + expect(await actions.approval(old, lateGrant)).toBe(false); + finishSave(); + expect(await save).toBe(true); + expect(await actions.approval(old, lateGrant)).toBe(false); + expect(lateGrant).not.toHaveBeenCalled(); + expect(duplicate).not.toHaveBeenCalled(); + expect(granted).toBe(false); + }, +); + +it("retains the old shortcut and permits retry when revocation fails", async () => { + const actions = createAgentShortcutActions(old); + const persist = vi.fn(); + await expect( + actions.save( + old, + next, + async () => { + throw new Error("offline"); + }, + persist, + ), + ).rejects.toThrow("offline"); + expect(persist).not.toHaveBeenCalled(); + expect(await actions.approval(old, async () => {})).toBe(true); +}); diff --git a/apps/web/src/components/sidebar/agentShortcut.ts b/apps/web/src/components/sidebar/agentShortcut.ts new file mode 100644 index 0000000000000..3d3151c7e6ca9 --- /dev/null +++ b/apps/web/src/components/sidebar/agentShortcut.ts @@ -0,0 +1,62 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +type Shortcut = ScopedThreadRef & { readonly name: string }; + +export async function saveAgentShortcut( + previous: Shortcut | null, + next: Shortcut | null, + revoke: (ref: ScopedThreadRef) => Promise, + save: (next: Shortcut | null) => Promise, +): Promise { + if (previous && (!next || scopedThreadKey(previous) !== scopedThreadKey(next))) { + await revoke(previous); + } + await save(next); +} + +export function createAgentShortcutActions(initial: Shortcut | null) { + let current = initial; + let saving = false; + let pendingApproval: Promise | null = null; + const matches = (ref: ScopedThreadRef | null) => + ref === null + ? current === null + : current !== null && scopedThreadKey(ref) === scopedThreadKey(current); + + return { + isSaving: () => saving, + syncShortcut: (shortcut: Shortcut | null) => { + current = shortcut; + }, + async approval(ref: ScopedThreadRef, update: () => Promise): Promise { + if (saving || pendingApproval || !matches(ref)) return false; + const pending = Promise.resolve().then(update); + pendingApproval = pending; + try { + await pending; + return true; + } finally { + pendingApproval = null; + } + }, + async save( + previous: Shortcut | null, + next: Shortcut | null, + revoke: (ref: ScopedThreadRef) => Promise, + persist: (next: Shortcut | null) => Promise, + ): Promise { + if (saving || !matches(previous)) return false; + saving = true; + try { + // Even a failed approval update must settle before the final revocation. + await pendingApproval?.catch(() => {}); + await saveAgentShortcut(previous, next, revoke, persist); + current = next; + return true; + } finally { + saving = false; + } + }, + }; +} diff --git a/apps/web/src/components/sidebar/agentStatus.test.ts b/apps/web/src/components/sidebar/agentStatus.test.ts new file mode 100644 index 0000000000000..978702057f65c --- /dev/null +++ b/apps/web/src/components/sidebar/agentStatus.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; +import { makeThreadFixture } from "../../test-fixtures"; +import { resolveAgentStatus } from "./agentStatus"; +import { ProviderInstanceId, RunId } from "@t3tools/contracts"; + +const runtime = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-10-24T10:00:00Z", +}; +const latestRun = { + runId: RunId.make("run-one"), + status: "completed" as const, + assistantMessageId: null, + requestedAt: "2026-10-24T10:00:00Z", + startedAt: "2026-10-24T10:00:00Z", + completedAt: "2026-10-24T10:05:00Z", +}; + +describe("Teddy status", () => { + it("prioritizes approvals and questions over ongoing work", () => { + expect(resolveAgentStatus(makeThreadFixture({ runtime })).label).toBe("Working"); + expect( + resolveAgentStatus(makeThreadFixture({ runtime, hasPendingApprovals: true })).label, + ).toBe("Approval"); + expect( + resolveAgentStatus(makeThreadFixture({ runtime, hasPendingUserInput: true })).label, + ).toBe("Needs you"); + expect( + resolveAgentStatus(makeThreadFixture({ runtime, pendingSubagentRequest: "approval" })).label, + ).toBe("Approval"); + }); + it("shows unread completion until visited, honoring mark-unread on the server", () => { + const thread = makeThreadFixture({ + runtime: null, + latestRun, + lastVisitedAt: "2026-10-24T10:01:00Z", + }); + expect(resolveAgentStatus(thread).label).toBe("Unread"); + expect(resolveAgentStatus({ ...thread, lastVisitedAt: "2026-10-24T10:06:00Z" }).label).toBe( + "Read", + ); + expect(resolveAgentStatus(thread, "2026-10-24T10:07:00Z").label).toBe("Unread"); + expect(resolveAgentStatus({ ...thread, lastVisitedAt: null }).label).toBe("Unread"); + }); + it("keeps plan review and missing status visible", () => { + expect( + resolveAgentStatus(makeThreadFixture({ runtime: null, hasActionableProposedPlan: true })) + .label, + ).toBe("Review plan"); + expect(resolveAgentStatus(null).label).toBe("Unavailable"); + }); +}); diff --git a/apps/web/src/components/sidebar/agentStatus.ts b/apps/web/src/components/sidebar/agentStatus.ts new file mode 100644 index 0000000000000..6dc88ddd4e9ac --- /dev/null +++ b/apps/web/src/components/sidebar/agentStatus.ts @@ -0,0 +1,38 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + hasUnseenCompletion, + resolveSidebarThreadStatus, + resolveThreadLastVisitedAt, +} from "../Sidebar.logic"; + +export function resolveAgentStatus(thread: EnvironmentThreadShell | null, localVisitedAt?: string) { + if (!thread) return { label: "Unavailable", color: "text-muted-foreground" }; + const status = resolveSidebarThreadStatus(thread); + switch (status) { + case "working": + return { label: "Working", color: "text-info" }; + case "waiting": + return { label: "Waiting", color: "text-muted-foreground" }; + case "approval": + return { label: "Approval", color: "text-warning-foreground" }; + case "input": + return { label: "Needs you", color: "text-warning-foreground" }; + case "failed": + return { label: "Failed", color: "text-error" }; + case "limited": + return { label: "Limited", color: "text-warning-foreground" }; + } + if (thread.hasActionableProposedPlan) + return { label: "Review plan", color: "text-warning-foreground" }; + const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisitedAt); + if ( + hasUnseenCompletion({ ...thread, lastVisitedAt }) || + (!lastVisitedAt && thread.latestRun?.completedAt) + ) { + return { label: "Unread", color: "text-success" }; + } + return { + label: thread.latestRun?.completedAt ? "Read" : "Ready", + color: "text-muted-foreground", + }; +} diff --git a/apps/web/src/components/usage/UsageAccounts.browser.test.tsx b/apps/web/src/components/usage/UsageAccounts.browser.test.tsx index 85c6628d1b753..62e5cca6daffa 100644 --- a/apps/web/src/components/usage/UsageAccounts.browser.test.tsx +++ b/apps/web/src/components/usage/UsageAccounts.browser.test.tsx @@ -1,9 +1,10 @@ import { EnvironmentId, IsoDateTime, ProviderDriverKind } from "@t3tools/contracts"; import type { UsageAccount } from "@t3tools/shared/usageLimits"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../../test/render"; +import { useEscapeToGoBack } from "../../hooks/useNavigateBack"; import "../../index.css"; import { UsageAccounts } from "./UsageAccounts"; import { usageAccountOrderId } from "./usageAccountOrder"; @@ -49,13 +50,7 @@ function Harness({ const [preferences, setPreferences] = useState(() => persist ? readUsagePagePreferences() : { metric: "limits", windowDays: 30 }, ); - useEffect(() => { - const goBack = (event: KeyboardEvent) => { - if (event.key === "Escape" && !event.defaultPrevented) onBack(); - }; - window.addEventListener("keydown", goBack); - return () => window.removeEventListener("keydown", goBack); - }, [onBack]); + useEscapeToGoBack(onBack); const update = (patch: Partial) => { const next = { ...preferences, ...patch }; setPreferences(next); diff --git a/apps/web/src/hooks/useNavigateBack.ts b/apps/web/src/hooks/useNavigateBack.ts new file mode 100644 index 0000000000000..7b96a0007567d --- /dev/null +++ b/apps/web/src/hooks/useNavigateBack.ts @@ -0,0 +1,39 @@ +import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +function useNavigateBack() { + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + + return () => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }; +} + +export function useEscapeToGoBack(onEscape?: () => void) { + const navigateBack = useNavigateBack(); + const handleEscape = onEscape ?? navigateBack; + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Subscribe to the browser keyboard event and remove the listener on unmount. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.isComposing || event.key !== "Escape") + return; + event.preventDefault(); + + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + + handleEscape(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleEscape]); +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a7ccbefd21774..439a5fa27a7d6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1479,6 +1479,12 @@ export function createServerEnvironmentAtoms( }), updateSettings, + setApprovalCoordinator: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:set-approval-coordinator", + tag: WS_METHODS.serverSetApprovalCoordinator, + scheduler: configScheduler, + concurrency: configConcurrency, + }), mutateProviderInstance: updateSettings, prepareAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:acp-registry:prepare", @@ -1617,7 +1623,37 @@ export function createServerEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.turnItemId]), }, }), - + n2itionStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:n2ition:status", + tag: WS_METHODS.n2itionStatus, + }), + n2itionControl: createEnvironmentRpcCommand(runtime, { + label: "environment-data:n2ition:control", + tag: WS_METHODS.n2itionControl, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + n2itionRefresh: createEnvironmentRpcCommand(runtime, { + label: "environment-data:n2ition:refresh", + tag: WS_METHODS.n2itionRefresh, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + n2itionReconnect: createEnvironmentRpcCommand(runtime, { + label: "environment-data:n2ition:reconnect", + tag: WS_METHODS.n2itionReconnect, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + getPrivateHelperState: createEnvironmentRpcCommand(runtime, { + label: "environment-data:privacy:helper-state", + tag: WS_METHODS.privacyGetHelperState, + }), + decidePrivateHelper: createEnvironmentRpcCommand(runtime, { + label: "environment-data:privacy:helper-decision", + tag: WS_METHODS.privacyDecideHelper, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.packetId}`, + }, + }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index fd098d3ad26ff..80a318e32bf5d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -18,6 +18,10 @@ "./relay": { "types": "./src/relay.ts", "import": "./src/relay.ts" + }, + "./n2itionConnection": { + "types": "./src/n2itionConnection.ts", + "import": "./src/n2itionConnection.ts" } }, "scripts": { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 2726ba74aaf14..71082c33e86cc 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -18,11 +18,17 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.privacyGetHelperState]: AuthOrchestrationReadScope, + [WS_METHODS.privacyDecideHelper]: AuthOrchestrationOperateScope, [WS_METHODS.projectsGetBrief]: AuthOrchestrationReadScope, [WS_METHODS.projectsGenerateBrief]: AuthOrchestrationOperateScope, [WS_METHODS.projectsClearBrief]: AuthOrchestrationOperateScope, [WS_METHODS.projectsConfigureBrief]: AuthOrchestrationOperateScope, - + [WS_METHODS.serverSetApprovalCoordinator]: AuthSettingsWriteScope, + [WS_METHODS.n2itionStatus]: AuthSettingsWriteScope, + [WS_METHODS.n2itionControl]: AuthSettingsWriteScope, + [WS_METHODS.n2itionRefresh]: AuthSettingsWriteScope, + [WS_METHODS.n2itionReconnect]: AuthSettingsWriteScope, [WS_METHODS.accountRoutingPreview]: AuthOrchestrationReadScope, [WS_METHODS.providerConnectTinfoilBilling]: AuthProvidersManageScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ee9737d526f15..73805498694f6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -80,3 +80,7 @@ export * from "./clientRpcPermissions.ts"; export * from "./upstream.ts"; export * from "./providerBilling.ts"; + +export * from "./privateHelpers.ts"; + +export * from "./n2ition.ts"; diff --git a/packages/contracts/src/n2ition.ts b/packages/contracts/src/n2ition.ts new file mode 100644 index 0000000000000..b48dac05a95cb --- /dev/null +++ b/packages/contracts/src/n2ition.ts @@ -0,0 +1,108 @@ +import * as Schema from "effect/Schema"; + +const N2ITION_MAX_TASKS = 5_000; +const N2ITION_MAX_BYTES = 2 * 1024 * 1024; +export const N2ITION_CONTEXT_MAX_BYTES = 64 * 1024; +export const N2ITION_TIMEZONE = "America/Chicago" as const; + +function isDateTime(value: string, utcOnly: boolean): boolean { + const match = value.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/, + ); + if (!match || (utcOnly && match[7] !== "Z") || !Number.isFinite(Date.parse(value))) return false; + const [ + , + yearText, + monthText, + dayText, + hourText, + minuteText, + secondText, + , + , + offsetHour, + offsetMinute, + ] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= monthDays[month - 1]! && + hour <= 23 && + minute <= 59 && + second <= 59 && + (offsetHour === undefined || (Number(offsetHour) <= 23 && Number(offsetMinute) <= 59)) + ); +} + +const DateTimeWithOffset = Schema.String.check( + Schema.makeFilter((value) => isDateTime(value, false)), +); +const UtcDateTime = Schema.String.check(Schema.makeFilter((value) => isDateTime(value, true))); +const NullableDateTimeWithOffset = Schema.NullOr(DateTimeWithOffset); +const NullableBoundedString = (maxLength: number) => + Schema.NullOr(Schema.String.check(Schema.isMaxLength(maxLength))); + +export const N2itionTask = Schema.Struct({ + id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128)), + title: Schema.String.check(Schema.isMaxLength(4_096)), + notes: NullableBoundedString(65_536), + priority: Schema.Literals(["low", "medium", "high", "urgent"]), + status: Schema.Literals(["pending", "in_progress", "completed", "snoozed"]), + dueDate: NullableDateTimeWithOffset, + timeBand: Schema.NullOr( + Schema.Literals(["morning", "midday", "afternoon", "evening", "anytime"]), + ), + domain: NullableBoundedString(256), + sortOrder: Schema.NullOr(Schema.Finite), + snoozedUntil: NullableDateTimeWithOffset, + completedAt: NullableDateTimeWithOffset, + createdAt: DateTimeWithOffset, + contextType: Schema.Literals(["none", "contact", "self"]), + recurrenceSeriesId: NullableBoundedString(128), + recurrenceInstanceDueDate: NullableDateTimeWithOffset, + recurrenceSequence: Schema.NullOr(Schema.Int), +}); +export type N2itionTask = typeof N2itionTask.Type; + +const N2itionTaskRows = Schema.Array(N2itionTask).check( + Schema.isMaxLength(N2ITION_MAX_TASKS), + Schema.makeFilter((tasks) => new Set(tasks.map(({ id }) => id)).size === tasks.length), +); + +export const N2itionSnapshot = Schema.Struct({ + version: Schema.Literal(1), + source: Schema.Literals(["synthetic", "live"]), + generatedAt: UtcDateTime, + expiresAt: UtcDateTime, + digest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + epoch: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + complete: Schema.Literal(true), + timezone: Schema.Literal(N2ITION_TIMEZONE), + tasks: N2itionTaskRows, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type N2itionSnapshot = typeof N2itionSnapshot.Type; + +const decodeSnapshot = Schema.decodeUnknownSync(N2itionSnapshot, { onExcessProperty: "error" }); + +/** Decode the bounded JSON wire payload and reject unknown properties at every level. */ +export function decodeN2itionSnapshot(input: unknown): N2itionSnapshot { + let json: string | undefined; + try { + json = JSON.stringify(input); + } catch { + throw new TypeError("N2ition snapshot must be JSON serializable"); + } + if (json === undefined || new TextEncoder().encode(json).byteLength > N2ITION_MAX_BYTES) { + throw new RangeError("N2ition snapshot exceeds the 2 MiB wire limit"); + } + return decodeSnapshot(input); +} diff --git a/packages/contracts/src/n2itionConnection.ts b/packages/contracts/src/n2itionConnection.ts new file mode 100644 index 0000000000000..a61e06d4b1b14 --- /dev/null +++ b/packages/contracts/src/n2itionConnection.ts @@ -0,0 +1,107 @@ +import * as Schema from "effect/Schema"; +import { EnvironmentId, ThreadId, IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export class N2itionConnectionError extends Schema.TaggedError()( + "N2itionConnectionError", + { + reason: Schema.Literals([ + "disabled", + "paused", + "revoked", + "expired", + "unauthorized", + "route_changed", + "privacy_required", + "unavailable", + "too_large", + "invalid_data", + "rate_limited", + "changed", + "identity_unverified", + "enrollment_required", + ]), + }, +) { + override get message() { + if (this.reason === "route_changed") + return "N2ITION context is paused because its approved inference route or source credential changed. Re-enroll this environment's live connection after verifying the route."; + return `N2ITION context: ${this.reason}.`; + } +} +const Digest = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); +const BoundString = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +export const N2itionInferenceIdentity = Schema.Struct({ + version: Schema.Literal(1), + upstreamProvider: Schema.Literal("openai-codex"), + upstreamModel: BoundString, + endpoint: Schema.Literal("https://chatgpt.com/backend-api/codex"), + profileHome: BoundString, + credentialSource: BoundString, + accountHash: Digest, + runtimePolicyHash: Digest, +}); +export type N2itionInferenceIdentity = typeof N2itionInferenceIdentity.Type; +const LegacyRouteBinding = Schema.Struct({ + providerConfigHash: Digest, + accountRouteHash: Digest, +}); +const VerifiedRouteBinding = Schema.Struct({ + version: Schema.Literal(2), + providerConfigHash: Digest, + accountRouteHash: Digest, + identity: N2itionInferenceIdentity, +}); +export const N2itionGrant = Schema.Struct({ + version: Schema.Literal(1), + environmentId: EnvironmentId, + threadId: ThreadId, + providerInstanceId: ProviderInstanceId, + model: TrimmedNonEmptyString, + source: Schema.Literals(["synthetic", "live"]), + origin: Schema.String, + mode: Schema.Literals(["active", "paused", "revoked"]), + epoch: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + expiresAt: IsoDateTime, + cadenceHours: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 6 })), + privacyVerified: Schema.Boolean, + sharedProcessAccepted: Schema.Boolean, + // Optional decoding preserves owner controls for legacy grants; live reads require this binding. + routeBinding: Schema.optionalKey(Schema.Union([VerifiedRouteBinding, LegacyRouteBinding])), + // Credential rotation requires explicit re-enrollment; optional decoding retains legacy controls. + sourceCredentialHash: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))), + // Metadata detects ordinary account/config changes; it provides no account attestation. + routeFiles: Schema.Array( + Schema.Struct({ path: Schema.String, fingerprint: Schema.String }), + ).check(Schema.isMaxLength(8)), +}); +export type N2itionGrant = typeof N2itionGrant.Type; +export const N2itionControlInput = Schema.Struct({ + action: Schema.Literals(["pause", "resume", "revoke"]), + expectedEpoch: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}); +export type N2itionControlInput = typeof N2itionControlInput.Type; +export const N2itionReconnectInput = Schema.Struct({ + expectedEpoch: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}); +export type N2itionReconnectInput = typeof N2itionReconnectInput.Type; +export class N2itionStatus extends Schema.Class("N2itionStatus")({ + localMode: Schema.NullOr(Schema.Literals(["active", "paused", "revoked"])), + state: Schema.String, + source: Schema.NullOr(Schema.Literals(["synthetic", "live"])), + epoch: Schema.NullOr(Schema.Int), + lastCheckedAt: Schema.NullOr(Schema.String), + lastError: Schema.NullOr(Schema.String), + nextCheckAt: Schema.NullOr(Schema.String), + cadenceHours: Schema.NullOr(Schema.Int), + expiresAt: Schema.NullOr(Schema.String), + taskCount: Schema.Int, + usage: Schema.Struct({ + reads: Schema.Int, + refreshes: Schema.Int, + failures: Schema.Int, + notesReads: Schema.Int, + returnedTasks: Schema.Int, + backgroundModelCalls: Schema.Literal(0), + }), +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index df6134f437a78..954971452ac0e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,7 +1,19 @@ +import { + PrivateHelperDecisionInput, + PrivateHelperError, + PrivateHelperPacket, + PrivateHelperState, + PrivateHelperStateInput, +} from "./privateHelpers.ts"; import { TextGenerationError } from "./git.ts"; -import { ProjectId } from "./baseSchemas.ts"; +import { ProjectId, ThreadId } from "./baseSchemas.ts"; import { ProjectBriefState, ProjectBriefError } from "./project.ts"; - +import { + N2itionStatus, + N2itionControlInput, + N2itionReconnectInput, + N2itionConnectionError, +} from "./n2itionConnection.ts"; import { ProviderConnectTinfoilBillingInput, ProviderConfigureClaudeBillingInput, @@ -431,6 +443,8 @@ import { VcsError } from "./vcs.ts"; import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; export const WS_METHODS = { + privacyGetHelperState: "privacy.getHelperState", + privacyDecideHelper: "privacy.decideHelper", projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", @@ -547,8 +561,13 @@ export const WS_METHODS = { serverRemoveLocalThreadHandoff: "server.removeLocalThreadHandoff", serverGetDatabaseStorage: "server.getDatabaseStorage", + serverSetApprovalCoordinator: "server.setApprovalCoordinator", serverGetSettings: "server.getSettings", accountRoutingPreview: "accountRouting.preview", + n2itionStatus: "n2ition.status", + n2itionControl: "n2ition.control", + n2itionRefresh: "n2ition.refresh", + n2itionReconnect: "n2ition.reconnect", serverReclaimDatabaseSpace: "server.reclaimDatabaseSpace", peerLinksList: "peerLinks.list", peerLinksLink: "peerLinks.link", @@ -885,16 +904,52 @@ const WsServerGetDatabaseStorageRpc = Rpc.make(WS_METHODS.serverGetDatabaseStora error: EnvironmentAuthorizationError, }); +const WsServerReclaimDatabaseSpaceRpc = Rpc.make(WS_METHODS.serverReclaimDatabaseSpace, { + payload: Schema.Struct({}), + error: Schema.Union([DatabaseReclaimError, EnvironmentAuthorizationError]), +}); + +const WsPrivacyGetHelperStateRpc = Rpc.make(WS_METHODS.privacyGetHelperState, { + payload: PrivateHelperStateInput, + success: PrivateHelperState, + error: Schema.Union([PrivateHelperError, EnvironmentAuthorizationError]), +}); +const WsPrivacyDecideHelperRpc = Rpc.make(WS_METHODS.privacyDecideHelper, { + payload: PrivateHelperDecisionInput, + success: PrivateHelperPacket, + error: Schema.Union([PrivateHelperError, EnvironmentAuthorizationError]), +}); +const WsN2itionStatusRpc = Rpc.make(WS_METHODS.n2itionStatus, { + payload: Schema.Struct({}), + success: N2itionStatus, + error: Schema.Union([N2itionConnectionError, EnvironmentAuthorizationError]), +}); +const WsN2itionControlRpc = Rpc.make(WS_METHODS.n2itionControl, { + payload: N2itionControlInput, + success: N2itionStatus, + error: Schema.Union([N2itionConnectionError, EnvironmentAuthorizationError]), +}); +const WsN2itionRefreshRpc = Rpc.make(WS_METHODS.n2itionRefresh, { + payload: Schema.Struct({}), + success: N2itionStatus, + error: Schema.Union([N2itionConnectionError, EnvironmentAuthorizationError]), +}); +const WsN2itionReconnectRpc = Rpc.make(WS_METHODS.n2itionReconnect, { + payload: N2itionReconnectInput, + success: N2itionStatus, + error: Schema.Union([N2itionConnectionError, EnvironmentAuthorizationError]), +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -const WsServerReclaimDatabaseSpaceRpc = Rpc.make(WS_METHODS.serverReclaimDatabaseSpace, { - payload: Schema.Struct({}), - error: Schema.Union([DatabaseReclaimError, EnvironmentAuthorizationError]), +const WsServerSetApprovalCoordinatorRpc = Rpc.make(WS_METHODS.serverSetApprovalCoordinator, { + payload: Schema.Struct({ threadId: ThreadId, enabled: Schema.Boolean }), + success: ServerSettings, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); - const WsPeerLinksListRpc = Rpc.make(WS_METHODS.peerLinksList, { payload: Schema.Struct({}), success: Schema.Struct({ links: Schema.Array(PeerLinkSummary) }), @@ -2210,12 +2265,17 @@ export const WsRpcGroup = RpcGroup.make( WsServerCreateLocalThreadHandoffRpc, WsServerRemoveLocalThreadHandoffRpc, WsServerGetDatabaseStorageRpc, - + WsPrivacyGetHelperStateRpc, + WsPrivacyDecideHelperRpc, + WsN2itionStatusRpc, + WsN2itionControlRpc, + WsN2itionRefreshRpc, + WsN2itionReconnectRpc, WsServerGetSettingsRpc, WsAccountRoutingPreviewRpc, WsServerReclaimDatabaseSpaceRpc, WsServerUpdateSettingsRpc, - + WsServerSetApprovalCoordinatorRpc, WsPeerLinksListRpc, WsPeerLinksLinkRpc, WsPeerLinksUnlinkRpc, diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index d757ed67567a7..7e672a66a0f62 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -61,6 +61,8 @@ describe("applyUsageLimitsUpdate", () => { }); it("returns the published object itself when no window moved", () => { + // Codex repeats the same numbers beside every token-usage tick; the + // ingestion path relies on identity to skip the publish. const next = applyUsageLimitsUpdate({ previous: published, checkedAt: "2026-09-03T12:00:05.000Z", diff --git a/packages/shared/src/providerConfigurationHash.ts b/packages/shared/src/providerConfigurationHash.ts index 0e287c0944a25..c7b3612a9033a 100644 --- a/packages/shared/src/providerConfigurationHash.ts +++ b/packages/shared/src/providerConfigurationHash.ts @@ -13,7 +13,7 @@ function canonicalValue(value: unknown): unknown { return value; } -function providerConfigurationHash(configuration: unknown): string { +export function providerConfigurationHash(configuration: unknown): string { return Hex.encode( sha256(new TextEncoder().encode(JSON.stringify(canonicalValue(configuration)))), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75d7741c52d33..07767209efa11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,7 +274,7 @@ overrides: body-parser@^2: 2.4.0 seroval@^1: 1.6.11 proxy-addr@^2: 2.0.8 - hono@^4: 4.13.11 + hono@^4: 4.13.16 ip-address@^10: 10.7.4 qs@>=6.7.0 <=6.16.0: 6.16.1 fast-uri@^3: 3.1.9 @@ -4487,7 +4487,7 @@ packages: resolution: {integrity: sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==} engines: {node: '>=20'} peerDependencies: - hono: 4.13.11 + hono: 4.13.16 '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} @@ -9648,8 +9648,8 @@ packages: resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==} hasBin: true - hono@4.13.11: - resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} + hono@4.13.16: + resolution: {integrity: sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -14790,9 +14790,9 @@ snapshots: '@handy-computer/models@0.2.3': {} - '@hono/node-server@2.1.6(hono@4.13.11)': + '@hono/node-server@2.1.6(hono@4.13.16)': dependencies: - hono: 4.13.11 + hono: 4.13.16 '@iarna/toml@2.2.5': {} @@ -15099,7 +15099,7 @@ snapshots: '@modelcontextprotocol/sdk@1.32.0(supports-color@8.1.1)(zod@4.6.6)': dependencies: - '@hono/node-server': 2.1.6(hono@4.13.11) + '@hono/node-server': 2.1.6(hono@4.13.16) ajv: 8.20.0 ajv-formats: 3.0.1 content-type: 1.0.5 @@ -15109,7 +15109,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1(supports-color@8.1.1) express-rate-limit: 8.7.3(express@5.2.1(supports-color@8.1.1))(supports-color@8.1.1) - hono: 4.13.11 + hono: 4.13.16 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -19525,7 +19525,7 @@ snapshots: hexy@0.2.11: {} - hono@4.13.11: {} + hono@4.13.16: {} hosted-git-info@4.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 322a7b2c17b86..460e80cb3940b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,7 +105,7 @@ overrides: "body-parser@^2": 2.4.0 "seroval@^1": 1.6.11 "proxy-addr@^2": 2.0.8 - "hono@^4": 4.13.11 + "hono@^4": 4.13.16 "ip-address@^10": 10.7.4 "qs@>=6.7.0 <=6.16.0": 6.16.1 "fast-uri@^3": 3.1.9